From e66e7a58846c0369687978c406bae0b2aac10061 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 25 Sep 2025 11:44:10 +0900 Subject: [PATCH] Update TypeScript configuration to include scripts directory, clean up unused lines in various files, and refactor error handling in cache service. Enhance logging for database and Redis connections, and streamline order processing logic in the orders module. Improve validation schemas and ensure consistent import/export practices across modules. --- apps/bff/scripts/generate-openapi.ts | 2 - apps/bff/src/app/bootstrap.ts | 48 +-- apps/bff/src/core/config/app.config.ts | 4 +- apps/bff/src/core/config/env.validation.ts | 2 - apps/bff/src/core/config/field-map.ts | 1 - apps/bff/src/core/config/router.config.ts | 2 - apps/bff/src/core/config/throttler.config.ts | 2 - apps/bff/src/core/http/auth-error.filter.ts | 103 ++++--- .../src/core/http/http-exception.filter.ts | 2 - .../core/http/success-response.interceptor.ts | 4 +- apps/bff/src/core/logging/logging.module.ts | 2 - apps/bff/src/core/utils/error.util.ts | 2 - apps/bff/src/core/utils/validation.util.ts | 9 +- apps/bff/src/core/validation/index.ts | 8 +- apps/bff/src/infra/audit/audit.module.ts | 2 - apps/bff/src/infra/audit/audit.service.ts | 1 - apps/bff/src/infra/cache/cache.module.ts | 2 - apps/bff/src/infra/cache/cache.service.ts | 14 +- apps/bff/src/infra/database/prisma.module.ts | 2 - apps/bff/src/infra/database/prisma.service.ts | 2 - apps/bff/src/infra/email/email.module.ts | 2 - apps/bff/src/infra/email/email.service.ts | 2 - .../email/providers/sendgrid.provider.ts | 2 - .../src/infra/email/queue/email.processor.ts | 2 - apps/bff/src/infra/email/queue/email.queue.ts | 2 - apps/bff/src/infra/queue/queue.constants.ts | 2 - apps/bff/src/infra/queue/queue.module.ts | 2 - apps/bff/src/infra/redis/redis.module.ts | 2 - .../src/integrations/integrations.module.ts | 2 - .../salesforce/salesforce.service.ts | 4 +- .../services/salesforce-account.service.ts | 7 +- .../src/integrations/salesforce/types.d.ts | 2 - .../utils/__tests__/soql.util.spec.ts | 39 +++ .../salesforce/utils/soql.util.ts | 22 ++ .../whmcs/services/whmcs-invoice.service.ts | 143 +++++---- .../whmcs/services/whmcs-payment.service.ts | 6 +- .../transformers/whmcs-data.transformer.ts | 20 +- apps/bff/src/main.ts | 4 +- .../src/modules/auth/auth-zod.controller.ts | 14 +- apps/bff/src/modules/auth/auth.module.ts | 6 +- apps/bff/src/modules/auth/auth.service.ts | 14 +- .../auth/guards/auth-throttle.guard.ts | 2 +- .../auth/services/token-blacklist.service.ts | 10 +- .../modules/auth/services/token.service.ts | 112 ++++--- .../workflows/password-workflow.service.ts | 7 +- .../workflows/signup-workflow.service.ts | 10 +- .../workflows/whmcs-link-workflow.service.ts | 6 +- .../modules/auth/strategies/jwt.strategy.ts | 3 +- .../catalog/services/base-catalog.service.ts | 42 +-- .../services/internet-catalog.service.ts | 40 ++- .../catalog/services/sim-catalog.service.ts | 51 ++-- .../catalog/services/vpn-catalog.service.ts | 17 +- .../utils/salesforce-product.mapper.ts | 289 +++++++++--------- .../utils/salesforce-product.pricing.ts | 64 ---- .../src/modules/health/health.controller.ts | 2 - .../cache/mapping-cache.service.ts | 2 - .../modules/id-mappings/mappings.module.ts | 6 +- .../modules/id-mappings/mappings.service.ts | 32 +- .../id-mappings/types/mapping.types.ts | 8 +- .../validation/mapping-validator.service.ts | 59 ++-- .../src/modules/orders/orders.controller.ts | 5 +- apps/bff/src/modules/orders/orders.module.ts | 2 + .../orders/services/order-builder.service.ts | 175 +++++------ .../order-fulfillment-orchestrator.service.ts | 10 +- .../order-fulfillment-validator.service.ts | 55 ++-- .../services/order-item-builder.service.ts | 173 +++-------- .../services/order-orchestrator.service.ts | 230 +++++++++----- .../services/order-pricebook.service.ts | 128 ++++++++ .../services/order-validator.service.ts | 132 ++++---- .../services/order-whmcs-mapper.service.ts | 15 +- .../services/sim-fulfillment.service.ts | 53 +--- .../subscriptions/sim-management.service.ts | 6 +- .../subscriptions/subscriptions.controller.ts | 8 +- .../subscriptions/subscriptions.service.ts | 69 ++++- .../bff/src/modules/users/users.controller.ts | 4 +- apps/bff/src/modules/users/users.service.ts | 18 +- apps/bff/tsconfig.json | 2 +- .../src/features/auth/services/auth.store.ts | 94 ++---- .../features/orders/components/OrderCard.tsx | 123 +++----- .../orders/services/orders.service.ts | 79 ++--- .../features/orders/utils/order-presenters.ts | 168 ++++++++++ .../src/features/orders/views/OrderDetail.tsx | 230 +++++--------- .../src/features/orders/views/OrdersList.tsx | 95 +----- apps/portal/src/lib/api/index.ts | 1 + apps/portal/src/lib/utils/error-handling.ts | 155 +++++++++- packages/domain/src/contracts/salesforce.ts | 30 +- packages/domain/src/entities/invoice.ts | 43 +-- packages/domain/src/entities/user.ts | 8 +- .../domain/src/validation/api/responses.ts | 3 - packages/domain/src/validation/forms/auth.ts | 2 + packages/domain/src/validation/index.ts | 2 + .../domain/src/validation/shared/entities.ts | 11 + 92 files changed, 1751 insertions(+), 1653 deletions(-) create mode 100644 apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts create mode 100644 apps/bff/src/integrations/salesforce/utils/soql.util.ts create mode 100644 apps/bff/src/modules/orders/services/order-pricebook.service.ts create mode 100644 apps/portal/src/features/orders/utils/order-presenters.ts diff --git a/apps/bff/scripts/generate-openapi.ts b/apps/bff/scripts/generate-openapi.ts index 3ec1abff..7ed3520d 100644 --- a/apps/bff/scripts/generate-openapi.ts +++ b/apps/bff/scripts/generate-openapi.ts @@ -22,5 +22,3 @@ async function generate() { } void generate(); - - diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts index 541b18d1..17849fef 100644 --- a/apps/bff/src/app/bootstrap.ts +++ b/apps/bff/src/app/bootstrap.ts @@ -6,6 +6,17 @@ import { Logger } from "nestjs-pino"; import helmet from "helmet"; import cookieParser from "cookie-parser"; import * as express from "express"; +import type { CookieOptions, Response, NextFunction } from "express"; + +/* eslint-disable @typescript-eslint/no-namespace */ +declare global { + namespace Express { + interface Response { + setSecureCookie: (name: string, value: string, options?: CookieOptions) => void; + } + } +} +/* eslint-enable @typescript-eslint/no-namespace */ import { GlobalExceptionFilter } from "@bff/core/http/http-exception.filter"; import { AuthErrorFilter } from "@bff/core/http/auth-error.filter"; @@ -60,21 +71,18 @@ export async function bootstrap(): Promise { // Enhanced cookie parser with security options app.use(cookieParser()); - - // Configure session cookies with security options - app.use((req: any, res: any, next: any) => { - // Set secure cookie defaults - res.cookie = ((originalCookie) => { - return function(name: string, value: string, options: any = {}) { - const secureOptions = { - httpOnly: true, - secure: configService.get("NODE_ENV") === "production", - sameSite: "strict" as const, - ...options, - }; - return originalCookie.call(res, name, value, secureOptions); - }; - })(res.cookie.bind(res)); + + // Provide helper for secure cookie handling without mutating Express response methods + const secureCookieDefaults: CookieOptions = { + httpOnly: true, + sameSite: "strict", + secure: configService.get("NODE_ENV") === "production", + }; + + app.use((_req, res: Response, next: NextFunction) => { + res.setSecureCookie = (name: string, value: string, options: CookieOptions = {}) => { + res.cookie(name, value, { ...secureCookieDefaults, ...options }); + }; next(); }); @@ -160,11 +168,13 @@ export async function bootstrap(): Promise { // Enhanced startup information logger.log(`🚀 BFF API running on: http://localhost:${port}/api`); logger.log(`🌐 Frontend Portal: http://localhost:${configService.get("NEXT_PORT", 3000)}`); - logger.log( - `🗄️ Database: ${configService.get("DATABASE_URL", "postgresql://dev:dev@localhost:5432/portal_dev")}` - ); + if (configService.get("DATABASE_URL")) { + logger.log("🗄️ Database connection configured"); + } logger.log(`🔗 Prisma Studio: http://localhost:5555`); - logger.log(`🔴 Redis: ${configService.get("REDIS_URL", "redis://localhost:6379")}`); + if (configService.get("REDIS_URL")) { + logger.log("🔴 Redis connection configured"); + } if (configService.get("NODE_ENV") !== "production") { logger.log(`📚 API Documentation: http://localhost:${port}/docs`); diff --git a/apps/bff/src/core/config/app.config.ts b/apps/bff/src/core/config/app.config.ts index f9247585..0eda1ec3 100644 --- a/apps/bff/src/core/config/app.config.ts +++ b/apps/bff/src/core/config/app.config.ts @@ -1,4 +1,4 @@ -import { ConfigModuleOptions } from "@nestjs/config"; +import type { ConfigModuleOptions } from "@nestjs/config"; import { validate } from "./env.validation"; export const appConfig: ConfigModuleOptions = { @@ -6,5 +6,3 @@ export const appConfig: ConfigModuleOptions = { expandVariables: true, validate, }; - - diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index 8ab33293..4295a69a 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -82,5 +82,3 @@ export function validate(config: Record): Record `PricebookEntry.Product2.${f}`).join(", "); } - diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index 37c97a4c..0c004f7e 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -21,5 +21,3 @@ export const apiRoutes: Routes = [ ], }, ]; - - diff --git a/apps/bff/src/core/config/throttler.config.ts b/apps/bff/src/core/config/throttler.config.ts index 16e8a471..7823b9de 100644 --- a/apps/bff/src/core/config/throttler.config.ts +++ b/apps/bff/src/core/config/throttler.config.ts @@ -12,5 +12,3 @@ export const createThrottlerConfig = (configService: ConfigService): ThrottlerMo limit: configService.get("AUTH_RATE_LIMIT_LIMIT", 3), }, ]; - - diff --git a/apps/bff/src/core/http/auth-error.filter.ts b/apps/bff/src/core/http/auth-error.filter.ts index 63887c93..2eda5944 100644 --- a/apps/bff/src/core/http/auth-error.filter.ts +++ b/apps/bff/src/core/http/auth-error.filter.ts @@ -7,9 +7,9 @@ import { BadRequestException, ConflictException, HttpStatus, -} from '@nestjs/common'; -import { Response } from 'express'; -import { Logger } from 'nestjs-pino'; +} from "@nestjs/common"; +import { Response } from "express"; +import { Logger } from "nestjs-pino"; interface StandardErrorResponse { success: false; @@ -26,24 +26,27 @@ interface StandardErrorResponse { export class AuthErrorFilter implements ExceptionFilter { constructor(private readonly logger: Logger) {} - catch(exception: UnauthorizedException | ForbiddenException | BadRequestException | ConflictException, host: ArgumentsHost) { + catch( + exception: UnauthorizedException | ForbiddenException | BadRequestException | ConflictException, + host: ArgumentsHost + ) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception.getStatus(); const message = exception.message; - + // Map specific auth errors to user-friendly messages const userMessage = this.getUserFriendlyMessage(message, status); const errorCode = this.getErrorCode(message, status); // Log the error (without sensitive information) - this.logger.warn('Authentication error', { + this.logger.warn("Authentication error", { path: request.url, method: request.method, errorCode, - userAgent: request.headers['user-agent'], + userAgent: request.headers["user-agent"], ip: request.ip, }); @@ -63,82 +66,86 @@ export class AuthErrorFilter implements ExceptionFilter { private getUserFriendlyMessage(message: string, status: number): string { // Production-safe error messages that don't expose sensitive information if (status === HttpStatus.UNAUTHORIZED) { - if (message.includes('Invalid credentials') || message.includes('Invalid email or password')) { - return 'Invalid email or password. Please try again.'; + if ( + message.includes("Invalid credentials") || + message.includes("Invalid email or password") + ) { + return "Invalid email or password. Please try again."; } - if (message.includes('Token has been revoked') || message.includes('Invalid refresh token')) { - return 'Your session has expired. Please log in again.'; + if (message.includes("Token has been revoked") || message.includes("Invalid refresh token")) { + return "Your session has expired. Please log in again."; } - if (message.includes('Account is locked')) { - return 'Your account has been temporarily locked due to multiple failed login attempts. Please try again later.'; + if (message.includes("Account is locked")) { + return "Your account has been temporarily locked due to multiple failed login attempts. Please try again later."; } - if (message.includes('Unable to verify credentials')) { - return 'Unable to verify credentials. Please try again later.'; + if (message.includes("Unable to verify credentials")) { + return "Unable to verify credentials. Please try again later."; } - if (message.includes('Unable to verify account')) { - return 'Unable to verify account. Please try again later.'; + if (message.includes("Unable to verify account")) { + return "Unable to verify account. Please try again later."; } - return 'Authentication required. Please log in to continue.'; + return "Authentication required. Please log in to continue."; } if (status === HttpStatus.FORBIDDEN) { - if (message.includes('Admin access required')) { - return 'You do not have permission to access this resource.'; + if (message.includes("Admin access required")) { + return "You do not have permission to access this resource."; } - return 'Access denied. You do not have permission to perform this action.'; + return "Access denied. You do not have permission to perform this action."; } if (status === HttpStatus.BAD_REQUEST) { - if (message.includes('Salesforce account not found')) { - return 'Customer account not found. Please contact support.'; + if (message.includes("Salesforce account not found")) { + return "Customer account not found. Please contact support."; } - if (message.includes('Unable to verify customer information')) { - return 'Unable to verify customer information. Please contact support.'; + if (message.includes("Unable to verify customer information")) { + return "Unable to verify customer information. Please contact support."; } - return 'Invalid request. Please check your input and try again.'; + return "Invalid request. Please check your input and try again."; } if (status === HttpStatus.CONFLICT) { - if (message.includes('already linked')) { - return 'This billing account is already linked. Please sign in.'; + if (message.includes("already linked")) { + return "This billing account is already linked. Please sign in."; } - if (message.includes('already exists')) { - return 'An account with this email already exists. Please sign in.'; + if (message.includes("already exists")) { + return "An account with this email already exists. Please sign in."; } - return 'Conflict detected. Please try again.'; + return "Conflict detected. Please try again."; } - return 'Authentication error. Please try again.'; + return "Authentication error. Please try again."; } private getErrorCode(message: string, status: number): string { if (status === HttpStatus.UNAUTHORIZED) { - if (message.includes('Invalid credentials') || message.includes('Invalid email or password')) return 'INVALID_CREDENTIALS'; - if (message.includes('Token has been revoked')) return 'TOKEN_REVOKED'; - if (message.includes('Invalid refresh token')) return 'INVALID_REFRESH_TOKEN'; - if (message.includes('Account is locked')) return 'ACCOUNT_LOCKED'; - if (message.includes('Unable to verify credentials')) return 'SERVICE_UNAVAILABLE'; - if (message.includes('Unable to verify account')) return 'SERVICE_UNAVAILABLE'; - return 'UNAUTHORIZED'; + if (message.includes("Invalid credentials") || message.includes("Invalid email or password")) + return "INVALID_CREDENTIALS"; + if (message.includes("Token has been revoked")) return "TOKEN_REVOKED"; + if (message.includes("Invalid refresh token")) return "INVALID_REFRESH_TOKEN"; + if (message.includes("Account is locked")) return "ACCOUNT_LOCKED"; + if (message.includes("Unable to verify credentials")) return "SERVICE_UNAVAILABLE"; + if (message.includes("Unable to verify account")) return "SERVICE_UNAVAILABLE"; + return "UNAUTHORIZED"; } if (status === HttpStatus.FORBIDDEN) { - if (message.includes('Admin access required')) return 'ADMIN_REQUIRED'; - return 'FORBIDDEN'; + if (message.includes("Admin access required")) return "ADMIN_REQUIRED"; + return "FORBIDDEN"; } if (status === HttpStatus.BAD_REQUEST) { - if (message.includes('Salesforce account not found')) return 'CUSTOMER_NOT_FOUND'; - if (message.includes('Unable to verify customer information')) return 'SERVICE_UNAVAILABLE'; - return 'INVALID_REQUEST'; + if (message.includes("Salesforce account not found")) return "CUSTOMER_NOT_FOUND"; + if (message.includes("Unable to verify customer information")) return "SERVICE_UNAVAILABLE"; + return "INVALID_REQUEST"; } if (status === HttpStatus.CONFLICT) { - if (message.includes('already linked')) return 'ACCOUNT_ALREADY_LINKED'; - if (message.includes('already exists')) return 'ACCOUNT_EXISTS'; - return 'CONFLICT'; + if (message.includes("already linked")) return "ACCOUNT_ALREADY_LINKED"; + if (message.includes("already exists")) return "ACCOUNT_EXISTS"; + return "CONFLICT"; } - return 'AUTH_ERROR'; + return "AUTH_ERROR"; } } diff --git a/apps/bff/src/core/http/http-exception.filter.ts b/apps/bff/src/core/http/http-exception.filter.ts index 4c39398e..6f981060 100644 --- a/apps/bff/src/core/http/http-exception.filter.ts +++ b/apps/bff/src/core/http/http-exception.filter.ts @@ -81,5 +81,3 @@ export class GlobalExceptionFilter implements ExceptionFilter { response.status(status).json(errorResponse); } } - - diff --git a/apps/bff/src/core/http/success-response.interceptor.ts b/apps/bff/src/core/http/success-response.interceptor.ts index 18a57d36..cb0ff5c3 100644 --- a/apps/bff/src/core/http/success-response.interceptor.ts +++ b/apps/bff/src/core/http/success-response.interceptor.ts @@ -1,3 +1 @@ -export {} - - +export {}; diff --git a/apps/bff/src/core/logging/logging.module.ts b/apps/bff/src/core/logging/logging.module.ts index 7344fb44..754b999e 100644 --- a/apps/bff/src/core/logging/logging.module.ts +++ b/apps/bff/src/core/logging/logging.module.ts @@ -67,5 +67,3 @@ import { LoggerModule } from "nestjs-pino"; exports: [LoggerModule], }) export class LoggingModule {} - - diff --git a/apps/bff/src/core/utils/error.util.ts b/apps/bff/src/core/utils/error.util.ts index 43d0eda2..b3339424 100644 --- a/apps/bff/src/core/utils/error.util.ts +++ b/apps/bff/src/core/utils/error.util.ts @@ -200,5 +200,3 @@ export async function safeAsync( return { data: fallback ?? null, error }; } } - - diff --git a/apps/bff/src/core/utils/validation.util.ts b/apps/bff/src/core/utils/validation.util.ts index 5ba7008d..45c6b138 100644 --- a/apps/bff/src/core/utils/validation.util.ts +++ b/apps/bff/src/core/utils/validation.util.ts @@ -1,8 +1,11 @@ -import { z } from 'zod'; +import { z } from "zod"; import { BadRequestException } from "@nestjs/common"; // Simple Zod schemas for common validations -export const emailSchema = z.string().email().transform(email => email.toLowerCase().trim()); +export const emailSchema = z + .string() + .email() + .transform(email => email.toLowerCase().trim()); export const uuidSchema = z.string().uuid(); export function normalizeAndValidateEmail(email: string): string { @@ -20,5 +23,3 @@ export function validateUuidV4OrThrow(id: string): string { throw new Error("Invalid user ID format"); } } - - diff --git a/apps/bff/src/core/validation/index.ts b/apps/bff/src/core/validation/index.ts index 8568b257..f11a7756 100644 --- a/apps/bff/src/core/validation/index.ts +++ b/apps/bff/src/core/validation/index.ts @@ -3,9 +3,9 @@ * Direct Zod validation without separate validation package */ -import { ZodValidationPipe, createZodDto } from 'nestjs-zod'; -import type { ZodSchema } from 'zod'; -import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; +import { ZodValidationPipe, createZodDto } from "nestjs-zod"; +import type { ZodSchema } from "zod"; +import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from "@nestjs/common"; // Re-export the proper ZodPipe from nestjs-zod export { ZodValidationPipe, createZodDto }; @@ -23,7 +23,7 @@ export function ZodPipeClass(schema: ZodSchema) { const result = schema.safeParse(value); if (!result.success) { throw new BadRequestException({ - message: 'Validation failed', + message: "Validation failed", errors: result.error.issues, }); } diff --git a/apps/bff/src/infra/audit/audit.module.ts b/apps/bff/src/infra/audit/audit.module.ts index e8f05fdd..613d21c5 100644 --- a/apps/bff/src/infra/audit/audit.module.ts +++ b/apps/bff/src/infra/audit/audit.module.ts @@ -7,5 +7,3 @@ import { AuditService } from "./audit.service"; exports: [AuditService], }) export class AuditModule {} - - diff --git a/apps/bff/src/infra/audit/audit.service.ts b/apps/bff/src/infra/audit/audit.service.ts index 554f6265..72af4518 100644 --- a/apps/bff/src/infra/audit/audit.service.ts +++ b/apps/bff/src/infra/audit/audit.service.ts @@ -191,4 +191,3 @@ export class AuditService { }; } } - diff --git a/apps/bff/src/infra/cache/cache.module.ts b/apps/bff/src/infra/cache/cache.module.ts index 77876299..586efeb5 100644 --- a/apps/bff/src/infra/cache/cache.module.ts +++ b/apps/bff/src/infra/cache/cache.module.ts @@ -7,5 +7,3 @@ import { CacheService } from "./cache.service"; exports: [CacheService], }) export class CacheModule {} - - diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index 503c6f59..ff5752e0 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -11,7 +11,17 @@ export class CacheService { async get(key: string): Promise { const value = await this.redis.get(key); - return value ? (JSON.parse(value) as T) : null; + if (!value) { + return null; + } + + try { + return JSON.parse(value) as T; + } catch (error) { + this.logger.warn({ key, error }, "Failed to parse cached value; evicting entry"); + await this.redis.del(key); + return null; + } } async set(key: string, value: unknown, ttlSeconds?: number): Promise { @@ -75,5 +85,3 @@ export class CacheService { return fresh; } } - - diff --git a/apps/bff/src/infra/database/prisma.module.ts b/apps/bff/src/infra/database/prisma.module.ts index cea3537a..1edbf95e 100644 --- a/apps/bff/src/infra/database/prisma.module.ts +++ b/apps/bff/src/infra/database/prisma.module.ts @@ -7,5 +7,3 @@ import { PrismaService } from "./prisma.service"; exports: [PrismaService], }) export class PrismaModule {} - - diff --git a/apps/bff/src/infra/database/prisma.service.ts b/apps/bff/src/infra/database/prisma.service.ts index 0740dccc..64f29f0f 100644 --- a/apps/bff/src/infra/database/prisma.service.ts +++ b/apps/bff/src/infra/database/prisma.service.ts @@ -11,5 +11,3 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul await this.$disconnect(); } } - - diff --git a/apps/bff/src/infra/email/email.module.ts b/apps/bff/src/infra/email/email.module.ts index 9fba733c..13ff096f 100644 --- a/apps/bff/src/infra/email/email.module.ts +++ b/apps/bff/src/infra/email/email.module.ts @@ -12,5 +12,3 @@ import { EmailProcessor } from "./queue/email.processor"; exports: [EmailService, EmailQueueService], }) export class EmailModule {} - - diff --git a/apps/bff/src/infra/email/email.service.ts b/apps/bff/src/infra/email/email.service.ts index e237dd70..537edbf8 100644 --- a/apps/bff/src/infra/email/email.service.ts +++ b/apps/bff/src/infra/email/email.service.ts @@ -40,5 +40,3 @@ export class EmailService { } } } - - diff --git a/apps/bff/src/infra/email/providers/sendgrid.provider.ts b/apps/bff/src/infra/email/providers/sendgrid.provider.ts index ed5e5635..9e74ca80 100644 --- a/apps/bff/src/infra/email/providers/sendgrid.provider.ts +++ b/apps/bff/src/infra/email/providers/sendgrid.provider.ts @@ -52,5 +52,3 @@ export class SendGridEmailProvider { } } } - - diff --git a/apps/bff/src/infra/email/queue/email.processor.ts b/apps/bff/src/infra/email/queue/email.processor.ts index 3b215d9c..ed9102de 100644 --- a/apps/bff/src/infra/email/queue/email.processor.ts +++ b/apps/bff/src/infra/email/queue/email.processor.ts @@ -20,5 +20,3 @@ export class EmailProcessor extends WorkerHost { this.logger.debug("Processed email job"); } } - - diff --git a/apps/bff/src/infra/email/queue/email.queue.ts b/apps/bff/src/infra/email/queue/email.queue.ts index 5524c824..b60e3924 100644 --- a/apps/bff/src/infra/email/queue/email.queue.ts +++ b/apps/bff/src/infra/email/queue/email.queue.ts @@ -28,5 +28,3 @@ export class EmailQueueService { }); } } - - diff --git a/apps/bff/src/infra/queue/queue.constants.ts b/apps/bff/src/infra/queue/queue.constants.ts index 7d71fde9..522021ca 100644 --- a/apps/bff/src/infra/queue/queue.constants.ts +++ b/apps/bff/src/infra/queue/queue.constants.ts @@ -5,5 +5,3 @@ export const QUEUE_NAMES = { } as const; export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES]; - - diff --git a/apps/bff/src/infra/queue/queue.module.ts b/apps/bff/src/infra/queue/queue.module.ts index 9b2b692f..7c7af431 100644 --- a/apps/bff/src/infra/queue/queue.module.ts +++ b/apps/bff/src/infra/queue/queue.module.ts @@ -39,5 +39,3 @@ function parseRedisConnection(redisUrl: string) { exports: [BullModule], }) export class QueueModule {} - - diff --git a/apps/bff/src/infra/redis/redis.module.ts b/apps/bff/src/infra/redis/redis.module.ts index 8f2b8170..a21ae36b 100644 --- a/apps/bff/src/infra/redis/redis.module.ts +++ b/apps/bff/src/infra/redis/redis.module.ts @@ -32,5 +32,3 @@ import Redis from "ioredis"; exports: ["REDIS_CLIENT"], }) export class RedisModule {} - - diff --git a/apps/bff/src/integrations/integrations.module.ts b/apps/bff/src/integrations/integrations.module.ts index 3f43bd13..15a196b9 100644 --- a/apps/bff/src/integrations/integrations.module.ts +++ b/apps/bff/src/integrations/integrations.module.ts @@ -9,5 +9,3 @@ import { FreebititModule } from "@bff/integrations/freebit/freebit.module"; exports: [WhmcsModule, SalesforceModule, FreebititModule], }) export class IntegrationsModule {} - - diff --git a/apps/bff/src/integrations/salesforce/salesforce.service.ts b/apps/bff/src/integrations/salesforce/salesforce.service.ts index 77595155..00f4dc67 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.service.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.service.ts @@ -15,9 +15,7 @@ import { CreateCaseUserData, } from "./services/salesforce-case.service"; import { SupportCase, CreateCaseRequest } from "@customer-portal/domain"; -import type { - SalesforceAccountRecord, -} from "@customer-portal/domain"; +import type { SalesforceAccountRecord } from "@customer-portal/domain"; /** * Clean Salesforce Service - Only includes actually used functionality 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 afa7f0f7..e6c883d6 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -2,10 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { SalesforceConnection } from "./salesforce-connection.service"; -import type { - SalesforceAccountRecord, - SalesforceQueryResult, -} from "@customer-portal/domain"; +import type { SalesforceAccountRecord, SalesforceQueryResult } from "@customer-portal/domain"; export interface AccountData { name: string; @@ -134,7 +131,7 @@ export class SalesforceAccountService { WHERE Id = '${this.validateId(accountId)}' `)) as SalesforceQueryResult; - return result.totalSize > 0 ? result.records[0] ?? null : null; + return result.totalSize > 0 ? (result.records[0] ?? null) : null; } catch (error) { this.logger.error("Failed to get account", { error: getErrorMessage(error), diff --git a/apps/bff/src/integrations/salesforce/types.d.ts b/apps/bff/src/integrations/salesforce/types.d.ts index 273d1675..a82c29ba 100644 --- a/apps/bff/src/integrations/salesforce/types.d.ts +++ b/apps/bff/src/integrations/salesforce/types.d.ts @@ -1,3 +1 @@ declare module "salesforce-pubsub-api-client"; - - diff --git a/apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts b/apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts new file mode 100644 index 00000000..695699b5 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/utils/__tests__/soql.util.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from "@nestjs/common"; +import { assertSalesforceId, buildInClause, sanitizeSoqlLiteral } from "../soql.util"; + +describe("soql.util", () => { + describe("sanitizeSoqlLiteral", () => { + it("escapes single quotes and backslashes", () => { + const raw = "O'Reilly \\"books\\""; + const result = sanitizeSoqlLiteral(raw); + expect(result).toBe("O\\'Reilly \\\\"books\\\\""); + }); + + it("returns original string when no escaping needed", () => { + const raw = "Sample-Value"; + expect(sanitizeSoqlLiteral(raw)).toBe(raw); + }); + }); + + describe("assertSalesforceId", () => { + it("returns the id when valid", () => { + const id = "0015g00000N1ABC"; + expect(assertSalesforceId(id, "context")).toBe(id); + }); + + it("throws BadRequestException when id is invalid", () => { + expect(() => assertSalesforceId("invalid", "context")).toThrow(BadRequestException); + }); + }); + + describe("buildInClause", () => { + it("builds sanitized comma separated list", () => { + const clause = buildInClause(["abc", "O'Reilly"], "field"); + expect(clause).toBe("'abc', 'O\\'Reilly'"); + }); + + it("throws when no values provided", () => { + expect(() => buildInClause([], "field")).toThrow(BadRequestException); + }); + }); +}); diff --git a/apps/bff/src/integrations/salesforce/utils/soql.util.ts b/apps/bff/src/integrations/salesforce/utils/soql.util.ts new file mode 100644 index 00000000..f09c390d --- /dev/null +++ b/apps/bff/src/integrations/salesforce/utils/soql.util.ts @@ -0,0 +1,22 @@ +import { BadRequestException } from "@nestjs/common"; + +const SALESFORCE_ID_PATTERN = /^[a-zA-Z0-9]{15}(?:[a-zA-Z0-9]{3})?$/; + +export function sanitizeSoqlLiteral(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} + +export function assertSalesforceId(value: string | undefined | null, context: string): string { + if (!value || !SALESFORCE_ID_PATTERN.test(value)) { + throw new BadRequestException(`Invalid Salesforce ID for ${context}`); + } + return value; +} + +export function buildInClause(values: string[], context: string): string { + if (values.length === 0) { + throw new BadRequestException(`At least one value required for ${context}`); + } + + return values.map(raw => `'${sanitizeSoqlLiteral(raw)}'`).join(", "); +} diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index bdd4c4f5..181fb962 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -1,7 +1,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; -import { Invoice, InvoiceList } from "@customer-portal/domain"; +import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain"; import { WhmcsConnectionService } from "./whmcs-connection.service"; import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; @@ -10,6 +10,7 @@ import { WhmcsCreateInvoiceParams, WhmcsUpdateInvoiceParams, WhmcsCapturePaymentParams, + WhmcsInvoicesResponse, } from "../types/whmcs-api.types"; export interface InvoiceFilters { @@ -59,65 +60,27 @@ export class WhmcsInvoiceService { }; const response = await this.connectionService.getInvoices(params); + const transformed = this.transformInvoicesResponse(response, clientId, page, limit); - if (!response.invoices?.invoice) { - this.logger.warn(`No invoices found for client ${clientId}`); - return { - invoices: [], - pagination: { - page, - totalPages: 0, - totalItems: 0, - }, - }; + const parseResult = invoiceListSchema.safeParse(transformed); + if (!parseResult.success) { + this.logger.error("Failed to parse invoices response", { + error: parseResult.error.issues, + clientId, + page, + limit, + }); + throw new Error("Invalid invoice response from WHMCS"); } - // Transform invoices (note: items are not included by GetInvoices API) - const invoices = response.invoices.invoice - .map(whmcsInvoice => { - try { - return this.dataTransformer.transformInvoice(whmcsInvoice); - } catch (error) { - this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, { - error: getErrorMessage(error), - }); - return null; - } - }) - .filter((invoice): invoice is Invoice => invoice !== null); - - // Build result with pagination - this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, { - totalresults: response.totalresults, - numreturned: response.numreturned, - startnumber: response.startnumber, - actualInvoicesReturned: invoices.length, - requestParams: { - userid: clientId, - limitstart, - limitnum: limit, - orderby: "date", - order: "DESC", - }, - }); - - const totalItems = response.totalresults || 0; - const totalPages = Math.ceil(totalItems / limit); - - const result: InvoiceList = { - invoices, - pagination: { - page, - totalPages, - totalItems, - nextCursor: page < totalPages ? (page + 1).toString() : undefined, - }, - }; + const result = parseResult.data; // Cache the result await this.cacheService.setInvoicesList(userId, page, limit, status, result); - this.logger.log(`Fetched ${invoices.length} invoices for client ${clientId}, page ${page}`); + this.logger.log( + `Fetched ${result.invoices.length} invoices for client ${clientId}, page ${page}` + ); return result; } catch (error) { this.logger.error(`Failed to fetch invoices for client ${clientId}`, { @@ -147,7 +110,16 @@ export class WhmcsInvoiceService { try { // Get detailed invoice with items const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id); - return detailedInvoice; + const parseResult = invoiceSchema.safeParse(detailedInvoice); + if (!parseResult.success) { + this.logger.error("Failed to parse detailed invoice", { + error: parseResult.error.issues, + invoiceId: invoice.id, + clientId, + }); + throw new Error("Invalid invoice data"); + } + return parseResult.data; } catch (error) { this.logger.warn( `Failed to fetch details for invoice ${invoice.id}`, @@ -205,8 +177,8 @@ export class WhmcsInvoiceService { // Transform invoice const invoice = this.dataTransformer.transformInvoice(response); - // Validate transformation - if (!this.dataTransformer.validateInvoice(invoice)) { + const parseResult = invoiceSchema.safeParse(invoice); + if (!parseResult.success) { throw new Error(`Invalid invoice data after transformation`); } @@ -449,4 +421,63 @@ export class WhmcsInvoiceService { // Default fallback return "Unable to process payment. Please try again or contact support."; } + + private transformInvoicesResponse( + response: WhmcsInvoicesResponse, + clientId: number, + page: number, + limit: number + ): InvoiceList { + if (!response.invoices?.invoice) { + this.logger.warn(`No invoices found for client ${clientId}`); + return { + invoices: [], + pagination: { + page, + totalPages: 0, + totalItems: 0, + }, + } satisfies InvoiceList; + } + + const invoices = response.invoices.invoice + .map(whmcsInvoice => { + try { + return this.dataTransformer.transformInvoice(whmcsInvoice); + } catch (error) { + this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, { + error: getErrorMessage(error), + }); + return null; + } + }) + .filter((invoice): invoice is Invoice => invoice !== null); + + this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, { + totalresults: response.totalresults, + numreturned: response.numreturned, + startnumber: response.startnumber, + actualInvoicesReturned: invoices.length, + requestParams: { + userid: clientId, + limitstart: (page - 1) * limit, + limitnum: limit, + orderby: "date", + order: "DESC", + }, + }); + + const totalItems = response.totalresults || 0; + const totalPages = Math.ceil(totalItems / limit); + + return { + invoices, + pagination: { + page, + totalPages, + totalItems, + nextCursor: page < totalPages ? (page + 1).toString() : undefined, + }, + } satisfies InvoiceList; + } } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index 06b3acfa..ae64d953 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -50,7 +50,7 @@ export class WhmcsPaymentService { // Use consolidated array shape const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response?.paymethods) - ? (response.paymethods as WhmcsPaymentMethod[]) + ? response.paymethods : []; let methods = paymentMethodsArray @@ -69,7 +69,9 @@ export class WhmcsPaymentService { .filter((method): method is PaymentMethod => method !== null); // Mark the first method as default (per product decision) - methods = methods.map((m, i) => (i === 0 ? { ...m, isDefault: true } : { ...m, isDefault: false })); + methods = methods.map((m, i) => + i === 0 ? { ...m, isDefault: true } : { ...m, isDefault: false } + ); const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length }; if (!options?.fresh) { diff --git a/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts b/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts index 66bb37a4..dc1c910c 100644 --- a/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts +++ b/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts @@ -67,12 +67,10 @@ export class WhmcsDataTransformer { return invoice; } catch (error) { - this.logger.error(`Failed to transform invoice ${invoiceId}`, - { - error: getErrorMessage(error), - whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record), - } - ); + this.logger.error(`Failed to transform invoice ${invoiceId}`, { + error: getErrorMessage(error), + whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record), + }); throw new Error(`Failed to transform invoice: ${getErrorMessage(error)}`); } } @@ -132,12 +130,10 @@ export class WhmcsDataTransformer { return subscription; } catch (error) { - this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, - { - error: getErrorMessage(error), - whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record), - } - ); + this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, { + error: getErrorMessage(error), + whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record), + }); throw new Error(`Failed to transform subscription: ${getErrorMessage(error)}`); } } diff --git a/apps/bff/src/main.ts b/apps/bff/src/main.ts index 47d24966..b9cbfaef 100644 --- a/apps/bff/src/main.ts +++ b/apps/bff/src/main.ts @@ -32,10 +32,10 @@ for (const signal of signals) { } void bootstrap() - .then((startedApp) => { + .then(startedApp => { app = startedApp; }) - .catch((error) => { + .catch(error => { const resolvedError = error as Error; logger.error( `Failed to bootstrap the Nest application: ${resolvedError.message}`, diff --git a/apps/bff/src/modules/auth/auth-zod.controller.ts b/apps/bff/src/modules/auth/auth-zod.controller.ts index 2f26119a..c2cfc548 100644 --- a/apps/bff/src/modules/auth/auth-zod.controller.ts +++ b/apps/bff/src/modules/auth/auth-zod.controller.ts @@ -51,10 +51,7 @@ export class AuthController { @ApiResponse({ status: 409, description: "Customer already has account" }) @ApiResponse({ status: 400, description: "Customer number not found" }) @ApiResponse({ status: 429, description: "Too many validation attempts" }) - async validateSignup( - @Body() validateData: ValidateSignupRequestInput, - @Req() req: Request - ) { + async validateSignup(@Body() validateData: ValidateSignupRequestInput, @Req() req: Request) { return this.authService.validateSignup(validateData, req); } @@ -136,13 +133,10 @@ export class AuthController { @ApiResponse({ status: 200, description: "Token refreshed successfully" }) @ApiResponse({ status: 401, description: "Invalid refresh token" }) @ApiResponse({ status: 429, description: "Too many refresh attempts" }) - async refreshToken( - @Body() body: RefreshTokenRequestInput, - @Req() req: Request - ) { + async refreshToken(@Body() body: RefreshTokenRequestInput, @Req() req: Request) { return this.authService.refreshTokens(body.refreshToken, { deviceId: body.deviceId, - userAgent: req.headers['user-agent'], + userAgent: req.headers["user-agent"], }); } @@ -246,7 +240,7 @@ export class AuthController { description: "User not found or not linked to WHMCS", }) async createSsoLink( - @Req() req: Request & { user: { id: string } }, + @Req() req: Request & { user: { id: string } }, @Body() body: SsoLinkRequestInput ) { const destination = body?.destination; diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index dc2ea33f..7d9a03e6 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -48,10 +48,6 @@ import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workfl useClass: GlobalAuthGuard, }, ], - exports: [ - AuthService, - TokenBlacklistService, - AuthTokenService - ], + exports: [AuthService, TokenBlacklistService, AuthTokenService], }) export class AuthModule {} diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index 31b81b84..19abd203 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -150,7 +150,7 @@ export class AuthService { email: profile.email, }, { - userAgent: request?.headers['user-agent'], + userAgent: request?.headers["user-agent"], } ); @@ -232,7 +232,10 @@ export class AuthService { return null; } } catch (error) { - this.logger.error("Password validation error", { userId: user.id, error: getErrorMessage(error) }); + this.logger.error("Password validation error", { + userId: user.id, + error: getErrorMessage(error), + }); await this.auditService.logAuthEvent( AuditAction.LOGIN_FAILED, user.id, @@ -349,7 +352,6 @@ export class AuthService { return sanitizeWhmcsRedirectPath(path); } - async requestPasswordReset(email: string): Promise { await this.passwordWorkflow.requestPasswordReset(email); } @@ -432,7 +434,10 @@ export class AuthService { return this.passwordWorkflow.changePassword(userId, currentPassword, newPassword, request); } - async refreshTokens(refreshToken: string, deviceInfo?: { deviceId?: string; userAgent?: string }) { + async refreshTokens( + refreshToken: string, + deviceInfo?: { deviceId?: string; userAgent?: string } + ) { return this.tokenService.refreshTokens(refreshToken, deviceInfo); } @@ -443,5 +448,4 @@ export class AuthService { async signupPreflight(signupData: SignupRequestInput) { return this.signupWorkflow.signupPreflight(signupData); } - } diff --git a/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts b/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts index 3cced3c7..fb89d39d 100644 --- a/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts +++ b/apps/bff/src/modules/auth/guards/auth-throttle.guard.ts @@ -16,7 +16,7 @@ export class AuthThrottleGuard extends ThrottlerGuard { "unknown"; const userAgent = req.headers["user-agent"] || "unknown"; - const userAgentHash = Buffer.from(userAgent).toString('base64').slice(0, 16); + const userAgentHash = Buffer.from(userAgent).toString("base64").slice(0, 16); const resolvedIp = await Promise.resolve(ip); return `auth_${resolvedIp}_${userAgentHash}`; diff --git a/apps/bff/src/modules/auth/services/token-blacklist.service.ts b/apps/bff/src/modules/auth/services/token-blacklist.service.ts index f7afb30e..aa2abdbd 100644 --- a/apps/bff/src/modules/auth/services/token-blacklist.service.ts +++ b/apps/bff/src/modules/auth/services/token-blacklist.service.ts @@ -16,18 +16,15 @@ export class TokenBlacklistService { async blacklistToken(token: string, _expiresIn?: number): Promise { // Validate token format first - if (!token || typeof token !== 'string' || token.split('.').length !== 3) { + if (!token || typeof token !== "string" || token.split(".").length !== 3) { this.logger.warn("Invalid token format provided for blacklisting"); return; } // Use JwtService to safely decode and validate token try { - const payload = this.jwtService.decode(token) as { - exp?: number; - sub?: string; - } | null; - + const payload = this.jwtService.decode(token); + // Validate payload structure if (!payload || !payload.sub || !payload.exp) { this.logger.warn("Invalid JWT payload structure for blacklisting"); @@ -73,5 +70,4 @@ export class TokenBlacklistService { return false; } } - } diff --git a/apps/bff/src/modules/auth/services/token.service.ts b/apps/bff/src/modules/auth/services/token.service.ts index fa4df055..db4ce89a 100644 --- a/apps/bff/src/modules/auth/services/token.service.ts +++ b/apps/bff/src/modules/auth/services/token.service.ts @@ -29,18 +29,20 @@ export class AuthTokenService { private readonly usersService: UsersService ) {} - /** * Generate a new token pair with refresh token rotation */ - async generateTokenPair(user: { - id: string; - email: string; - role?: string; - }, deviceInfo?: { - deviceId?: string; - userAgent?: string; - }): Promise { + async generateTokenPair( + user: { + id: string; + email: string; + role?: string; + }, + deviceInfo?: { + deviceId?: string; + userAgent?: string; + } + ): Promise { const tokenId = this.generateTokenId(); const familyId = this.generateTokenId(); @@ -73,7 +75,7 @@ export class AuthTokenService { // Store refresh token family in Redis const refreshTokenHash = this.hashToken(refreshToken); const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); - + if (this.redis.status !== "ready") { this.logger.error("Redis not ready for token issuance", { status: this.redis.status }); throw new UnauthorizedException("Session service unavailable"); @@ -104,15 +106,19 @@ export class AuthTokenService { }) ); } catch (error) { - this.logger.error("Failed to store refresh token in Redis", { + this.logger.error("Failed to store refresh token in Redis", { error: error instanceof Error ? error.message : String(error), - userId: user.id + userId: user.id, }); throw new UnauthorizedException("Unable to issue session tokens. Please try again."); } - const accessExpiresAt = new Date(Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)).toISOString(); - const refreshExpiresAt = new Date(Date.now() + this.parseExpiryToMs(this.REFRESH_TOKEN_EXPIRY)).toISOString(); + const accessExpiresAt = new Date( + Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY) + ).toISOString(); + const refreshExpiresAt = new Date( + Date.now() + this.parseExpiryToMs(this.REFRESH_TOKEN_EXPIRY) + ).toISOString(); this.logger.debug("Generated new token pair", { userId: user.id, tokenId, familyId }); @@ -128,13 +134,16 @@ export class AuthTokenService { /** * Refresh access token using refresh token rotation */ - async refreshTokens(refreshToken: string, deviceInfo?: { - deviceId?: string; - userAgent?: string; - }): Promise { + async refreshTokens( + refreshToken: string, + deviceInfo?: { + deviceId?: string; + userAgent?: string; + } + ): Promise { try { // Verify refresh token - const payload = this.jwtService.verify(refreshToken) as RefreshTokenPayload; + const payload = this.jwtService.verify(refreshToken); const refreshTokenHash = this.hashToken(refreshToken); // Check if refresh token exists and is valid @@ -142,20 +151,24 @@ export class AuthTokenService { try { storedToken = await this.redis.get(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); } catch (error) { - this.logger.error("Redis error during token refresh", { - error: error instanceof Error ? error.message : String(error) + this.logger.error("Redis error during token refresh", { + error: error instanceof Error ? error.message : String(error), }); throw new UnauthorizedException("Token validation temporarily unavailable"); } - + if (!storedToken) { - this.logger.warn("Refresh token not found or expired", { tokenHash: refreshTokenHash.slice(0, 8) }); + this.logger.warn("Refresh token not found or expired", { + tokenHash: refreshTokenHash.slice(0, 8), + }); throw new UnauthorizedException("Invalid refresh token"); } const tokenData = JSON.parse(storedToken); if (!tokenData.valid) { - this.logger.warn("Refresh token marked as invalid", { tokenHash: refreshTokenHash.slice(0, 8) }); + this.logger.warn("Refresh token marked as invalid", { + tokenHash: refreshTokenHash.slice(0, 8), + }); // Invalidate entire token family on reuse attempt await this.invalidateTokenFamily(tokenData.familyId); throw new UnauthorizedException("Invalid refresh token"); @@ -185,8 +198,8 @@ export class AuthTokenService { return newTokenPair; } catch (error) { - this.logger.error("Token refresh failed", { - error: error instanceof Error ? error.message : String(error) + this.logger.error("Token refresh failed", { + error: error instanceof Error ? error.message : String(error), }); throw new UnauthorizedException("Invalid refresh token"); } @@ -199,17 +212,17 @@ export class AuthTokenService { try { const refreshTokenHash = this.hashToken(refreshToken); const storedToken = await this.redis.get(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); - + if (storedToken) { const tokenData = JSON.parse(storedToken); await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); await this.redis.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${tokenData.familyId}`); - + this.logger.debug("Revoked refresh token", { tokenHash: refreshTokenHash.slice(0, 8) }); } } catch (error) { - this.logger.error("Failed to revoke refresh token", { - error: error instanceof Error ? error.message : String(error) + this.logger.error("Failed to revoke refresh token", { + error: error instanceof Error ? error.message : String(error), }); } } @@ -221,7 +234,7 @@ export class AuthTokenService { try { const pattern = `${this.REFRESH_TOKEN_FAMILY_PREFIX}*`; const keys = await this.redis.keys(pattern); - + for (const key of keys) { const data = await this.redis.get(key); if (data) { @@ -232,11 +245,11 @@ export class AuthTokenService { } } } - + this.logger.debug("Revoked all tokens for user", { userId }); } catch (error) { - this.logger.error("Failed to revoke all user tokens", { - error: error instanceof Error ? error.message : String(error) + this.logger.error("Failed to revoke all user tokens", { + error: error instanceof Error ? error.message : String(error), }); } } @@ -246,18 +259,18 @@ export class AuthTokenService { const familyData = await this.redis.get(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`); if (familyData) { const family = JSON.parse(familyData); - + await this.redis.del(`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`); await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`); - - this.logger.warn("Invalidated token family due to security concern", { + + this.logger.warn("Invalidated token family due to security concern", { familyId: familyId.slice(0, 8), - userId: family.userId + userId: family.userId, }); } } catch (error) { - this.logger.error("Failed to invalidate token family", { - error: error instanceof Error ? error.message : String(error) + this.logger.error("Failed to invalidate token family", { + error: error instanceof Error ? error.message : String(error), }); } } @@ -273,13 +286,18 @@ export class AuthTokenService { private parseExpiryToMs(expiry: string): number { const unit = expiry.slice(-1); const value = 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; // Default 15 minutes + 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; // Default 15 minutes } } @@ -289,7 +307,7 @@ export class AuthTokenService { private calculateExpiryDate(expiresIn: string | number): string { const now = new Date(); - if (typeof expiresIn === 'number') { + if (typeof expiresIn === "number") { return new Date(now.getTime() + expiresIn * 1000).toISOString(); } return new Date(now.getTime() + this.parseExpiryToMs(expiresIn)).toISOString(); diff --git a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts index 2addf450..433dacfc 100644 --- a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - Inject, - Injectable, - UnauthorizedException, -} from "@nestjs/common"; +import { BadRequestException, Inject, Injectable, UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { Logger } from "nestjs-pino"; diff --git a/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts index 5208453e..be1dd2be 100644 --- a/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts @@ -27,10 +27,7 @@ import { import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; import type { User as PrismaUser } from "@prisma/client"; -type SanitizedPrismaUser = Omit< - PrismaUser, - "passwordHash" | "failedLoginAttempts" | "lockedUntil" ->; +type SanitizedPrismaUser = Omit; export interface SignupResult { user: UserProfile; @@ -438,9 +435,8 @@ export class SignupWorkflowService { private validateSignupData(signupData: SignupRequestInput) { const validation = signupRequestSchema.safeParse(signupData); if (!validation.success) { - const message = validation.error.issues - .map(issue => issue.message) - .join(". ") || "Invalid signup data"; + const message = + validation.error.issues.map(issue => issue.message).join(". ") || "Invalid signup data"; throw new BadRequestException(message); } } diff --git a/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts index 4bc3229e..f9ed93ac 100644 --- a/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts @@ -85,7 +85,7 @@ export class WhmcsLinkWorkflowService { this.logger.log("Found Customer Number for WHMCS client", { whmcsClientId: clientDetails.id, - hasCustomerNumber: !!customerNumber + hasCustomerNumber: !!customerNumber, }); let sfAccount: { id: string } | null; @@ -97,7 +97,9 @@ export class WhmcsLinkWorkflowService { } catch (error) { if (error instanceof BadRequestException) throw error; this.logger.error("Salesforce account lookup failed", { error: getErrorMessage(error) }); - throw new BadRequestException("Unable to verify customer information. Please contact support."); + throw new BadRequestException( + "Unable to verify customer information. Please contact support." + ); } const createdUser = await this.usersService.create({ diff --git a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts index 99fa8791..d441967f 100644 --- a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts +++ b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts @@ -20,7 +20,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: jwtSecret, - }; super(options); @@ -35,7 +34,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }): Promise { // Validate payload structure if (!payload.sub || !payload.email) { - throw new Error('Invalid JWT payload'); + throw new Error("Invalid JWT payload"); } // Return user info - token blacklist is checked in GlobalAuthGuard diff --git a/apps/bff/src/modules/catalog/services/base-catalog.service.ts b/apps/bff/src/modules/catalog/services/base-catalog.service.ts index 7be8135f..681e25d0 100644 --- a/apps/bff/src/modules/catalog/services/base-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/base-catalog.service.ts @@ -2,7 +2,10 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { getSalesforceFieldMap } from "@bff/core/config/field-map"; -import type { SalesforcePricebookEntryRecord } from "@customer-portal/domain"; +import type { + SalesforceProduct2WithPricebookEntries, + SalesforceQueryResult, +} from "@customer-portal/domain"; @Injectable() export class BaseCatalogService { @@ -19,49 +22,32 @@ export class BaseCatalogService { return getSalesforceFieldMap(); } - protected async executeQuery(soql: string, context: string): Promise[]> { + protected async executeQuery( + soql: string, + context: string + ): Promise { try { - const res = (await this.sf.query(soql)) as { records?: Array> }; - return res.records || []; + const res = (await this.sf.query(soql)) as SalesforceQueryResult; + return res.records ?? []; } catch (error) { this.logger.error({ error, soql, context }, `Query failed: ${context}`); return []; } } - protected extractPricebookEntry( - record: Record - ): SalesforcePricebookEntryRecord | undefined { - const nested = record["PricebookEntries"] as { - records?: Array>; - } | undefined; - const entry = nested?.records?.[0]; + protected extractPricebookEntry(record: SalesforceProduct2WithPricebookEntries) { + const entry = record.PricebookEntries?.records?.[0]; if (!entry) { const fields = this.getFields(); const skuField = fields.product.sku; const sku = record[skuField]; this.logger.warn( - `No pricebook entry found for product ${String(record["Name"])} (SKU: ${String( - typeof sku === "string" || typeof sku === "number" ? sku : "" - )}). Pricebook ID: ${this.portalPriceBookId}.` + `No pricebook entry found for product ${String(record.Name)} (SKU: ${String(sku ?? "")}). Pricebook ID: ${this.portalPriceBookId}.` ); return undefined; } - return { - Id: typeof entry["Id"] === "string" ? (entry["Id"] as string) : undefined, - Name: typeof entry["Name"] === "string" ? (entry["Name"] as string) : undefined, - UnitPrice: - typeof entry["UnitPrice"] === "number" || typeof entry["UnitPrice"] === "string" - ? (entry["UnitPrice"] as number | string) - : undefined, - Pricebook2Id: - typeof entry["Pricebook2Id"] === "string" ? (entry["Pricebook2Id"] as string) : undefined, - Product2Id: - typeof entry["Product2Id"] === "string" ? (entry["Product2Id"] as string) : undefined, - IsActive: - typeof entry["IsActive"] === "boolean" ? (entry["IsActive"] as boolean) : undefined, - }; + return entry; } protected buildProductQuery( diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 4b1563a3..e0372d90 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -1,11 +1,20 @@ import { Injectable, Inject } from "@nestjs/common"; import { BaseCatalogService } from "./base-catalog.service"; -import type { SalesforcePricebookEntryRecord, InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem } from "@customer-portal/domain"; +import type { + SalesforceProduct2WithPricebookEntries, + InternetPlanCatalogItem, + InternetInstallationCatalogItem, + InternetAddonCatalogItem, +} from "@customer-portal/domain"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { mapInternetPlan, mapInternetInstallation, mapInternetAddon } from "@bff/modules/catalog/utils/salesforce-product.mapper"; +import { + mapInternetPlan, + mapInternetInstallation, + mapInternetAddon, +} from "@bff/modules/catalog/utils/salesforce-product.mapper"; interface SalesforceAccount { Id: string; @@ -29,11 +38,14 @@ export class InternetCatalogService extends BaseCatalogService { fields.product.internetOfferingType, fields.product.displayOrder, ]); - const records = await this.executeQuery(soql, "Internet Plans"); + const records = await this.executeQuery( + soql, + "Internet Plans" + ); return records.map(record => { - const pricebookEntry = this.extractPricebookEntry(record); - return mapInternetPlan(record as SalesforceProduct2Record, pricebookEntry); + const entry = this.extractPricebookEntry(record); + return mapInternetPlan(record, entry); }); } @@ -43,14 +55,17 @@ export class InternetCatalogService extends BaseCatalogService { fields.product.billingCycle, fields.product.displayOrder, ]); - const records = await this.executeQuery(soql, "Internet Installations"); + const records = await this.executeQuery( + soql, + "Internet Installations" + ); this.logger.log(`Found ${records.length} installation records`); return records .map(record => { - const pricebookEntry = this.extractPricebookEntry(record); - return mapInternetInstallation(record as SalesforceProduct2Record, pricebookEntry); + const entry = this.extractPricebookEntry(record); + return mapInternetInstallation(record, entry); }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); } @@ -63,14 +78,17 @@ export class InternetCatalogService extends BaseCatalogService { fields.product.bundledAddon, fields.product.isBundledAddon, ]); - const records = await this.executeQuery(soql, "Internet Add-ons"); + const records = await this.executeQuery( + soql, + "Internet Add-ons" + ); this.logger.log(`Found ${records.length} addon records`); return records .map(record => { - const pricebookEntry = this.extractPricebookEntry(record); - return mapInternetAddon(record as SalesforceProduct2Record, pricebookEntry); + const entry = this.extractPricebookEntry(record); + return mapInternetAddon(record, entry); }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); } diff --git a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts index 74d40ca1..25b2f5f7 100644 --- a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts @@ -1,7 +1,14 @@ import { Injectable, Inject } from "@nestjs/common"; import { BaseCatalogService } from "./base-catalog.service"; -import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain"; -import { mapSimProduct } from "@bff/modules/catalog/utils/salesforce-product.mapper"; +import type { + SalesforceProduct2WithPricebookEntries, + SimCatalogProduct, + SimActivationFeeCatalogItem, +} from "@customer-portal/domain"; +import { + mapSimProduct, + mapSimActivationFee, +} from "@bff/modules/catalog/utils/salesforce-product.mapper"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { Logger } from "nestjs-pino"; @@ -26,17 +33,18 @@ export class SimCatalogService extends BaseCatalogService { fields.product.simHasFamilyDiscount, fields.product.displayOrder, ]); - const records = await this.executeQuery(soql, "SIM Plans"); + const records = await this.executeQuery( + soql, + "SIM Plans" + ); return records.map(record => { - const pricebookEntry = this.extractPricebookEntry(record); - const product = mapSimProduct(record, pricebookEntry); + const entry = this.extractPricebookEntry(record); + const product = mapSimProduct(record, entry); return { ...product, description: product.description ?? product.name, - monthlyPrice: product.monthlyPrice, - oneTimePrice: product.oneTimePrice, } satisfies SimCatalogProduct; }); } @@ -44,19 +52,14 @@ export class SimCatalogService extends BaseCatalogService { async getActivationFees(): Promise { const fields = this.getFields(); const soql = this.buildProductQuery("SIM", "Activation", []); - const records = await this.executeQuery(soql, "SIM Activation Fees"); + const records = await this.executeQuery( + soql, + "SIM Activation Fees" + ); return records.map(record => { - const pricebookEntry = this.extractPricebookEntry(record); - const product = mapSimProduct(record, pricebookEntry); - - return { - ...product, - description: product.description ?? product.name, - catalogMetadata: { - isDefault: true, - }, - } satisfies SimActivationFeeCatalogItem; + const entry = this.extractPricebookEntry(record); + return mapSimActivationFee(record, entry); }); } @@ -68,18 +71,19 @@ export class SimCatalogService extends BaseCatalogService { fields.product.bundledAddon, fields.product.isBundledAddon, ]); - const records = await this.executeQuery(soql, "SIM Add-ons"); + const records = await this.executeQuery( + soql, + "SIM Add-ons" + ); return records .map(record => { - const pricebookEntry = this.extractPricebookEntry(record); - const product = mapSimProduct(record, pricebookEntry); + const entry = this.extractPricebookEntry(record); + const product = mapSimProduct(record, entry); return { ...product, description: product.description ?? product.name, - displayOrder: - product.displayOrder ?? Number((record as Record)[fields.product.displayOrder] ?? 0), } satisfies SimCatalogProduct; }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); @@ -148,5 +152,4 @@ export class SimCatalogService extends BaseCatalogService { ]); return { plans, activationFees, addons }; } - } diff --git a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts index 0c0bb9ad..54487d40 100644 --- a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts @@ -1,6 +1,9 @@ import { Injectable } from "@nestjs/common"; import { BaseCatalogService } from "./base-catalog.service"; -import type { VpnCatalogProduct } from "@customer-portal/domain"; +import type { + SalesforceProduct2WithPricebookEntries, + VpnCatalogProduct, +} from "@customer-portal/domain"; import { mapVpnProduct } from "@bff/modules/catalog/utils/salesforce-product.mapper"; @Injectable() @@ -11,15 +14,17 @@ export class VpnCatalogService extends BaseCatalogService { fields.product.vpnRegion, fields.product.displayOrder, ]); - const records = await this.executeQuery(soql, "VPN Plans"); + const records = await this.executeQuery( + soql, + "VPN Plans" + ); return records.map(record => { - const pricebookEntry = this.extractPricebookEntry(record); - const product = mapVpnProduct(record, pricebookEntry); - + const entry = this.extractPricebookEntry(record); + const product = mapVpnProduct(record, entry); return { ...product, - description: product.description ?? product.name, + description: product.description || product.name, } satisfies VpnCatalogProduct; }); } diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts index 78f9acd5..b564a5dd 100644 --- a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts +++ b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts @@ -1,7 +1,7 @@ import type { CatalogProductBase, CatalogPricebookEntry, - SalesforceProduct2Record, + SalesforceProduct2WithPricebookEntries, SalesforcePricebookEntryRecord, InternetCatalogProduct, InternetPlanCatalogItem, @@ -12,69 +12,99 @@ import type { VpnCatalogProduct, } from "@customer-portal/domain"; import { getSalesforceFieldMap } from "@bff/core/config/field-map"; -import { getMonthlyPrice, getOneTimePrice } from "@bff/modules/catalog/utils/salesforce-product.pricing"; import type { InternetPlanTemplate } from "@customer-portal/domain"; -const { product: productFields } = getSalesforceFieldMap(); +const fieldMap = getSalesforceFieldMap(); -function normalizePricebookEntry(entry?: SalesforcePricebookEntryRecord): CatalogPricebookEntry | undefined { +export type SalesforceCatalogProductRecord = SalesforceProduct2WithPricebookEntries; + +function getProductField( + product: SalesforceCatalogProductRecord, + fieldKey: keyof typeof fieldMap.product +): T | undefined { + const salesforceField = fieldMap.product[fieldKey]; + const value = (product as Record)[salesforceField]; + return value as T | undefined; +} + +function getStringField( + product: SalesforceCatalogProductRecord, + fieldKey: keyof typeof fieldMap.product +): string | undefined { + const value = getProductField(product, fieldKey); + return typeof value === "string" ? value : undefined; +} + +function getBooleanField( + product: SalesforceCatalogProductRecord, + fieldKey: keyof typeof fieldMap.product +): boolean | undefined { + const value = getProductField(product, fieldKey); + return typeof value === "boolean" ? value : undefined; +} + +function coerceNumber(value: unknown): number | undefined { + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function buildPricebookEntry( + entry?: SalesforcePricebookEntryRecord +): CatalogPricebookEntry | undefined { if (!entry) return undefined; return { - id: typeof entry.Id === "string" ? entry.Id : undefined, - name: typeof entry.Name === "string" ? entry.Name : undefined, - unitPrice: - typeof entry.UnitPrice === "number" - ? entry.UnitPrice - : typeof entry.UnitPrice === "string" - ? Number.parseFloat(entry.UnitPrice) - : undefined, - pricebook2Id: typeof entry.Pricebook2Id === "string" ? entry.Pricebook2Id : undefined, - product2Id: typeof entry.Product2Id === "string" ? entry.Product2Id : undefined, - isActive: typeof entry.IsActive === "boolean" ? entry.IsActive : undefined, + id: entry.Id, + name: entry.Name, + unitPrice: coerceNumber(entry.UnitPrice), + pricebook2Id: entry.Pricebook2Id ?? undefined, + product2Id: entry.Product2Id ?? undefined, + isActive: entry.IsActive ?? undefined, }; } -function baseProduct(product: SalesforceProduct2Record): CatalogProductBase { - const safeRecord = product as Record; - const id = typeof product.Id === "string" ? product.Id : ""; - const skuField = productFields.sku; - const skuRaw = skuField ? safeRecord[skuField] : undefined; - const sku = typeof skuRaw === "string" ? skuRaw : ""; +function getPricebookEntry( + product: SalesforceCatalogProductRecord +): CatalogPricebookEntry | undefined { + const nested = product.PricebookEntries?.records; + if (!Array.isArray(nested) || nested.length === 0) return undefined; + return buildPricebookEntry(nested[0]); +} +function baseProduct(product: SalesforceCatalogProductRecord): CatalogProductBase { + const sku = getStringField(product, "sku") ?? ""; const base: CatalogProductBase = { - id, + id: product.Id, sku, - name: typeof product.Name === "string" ? product.Name : sku, + name: product.Name ?? sku, }; - const description = typeof product.Description === "string" ? product.Description : undefined; - if (description) { - base.description = description; - } + const description = product.Description; + if (description) base.description = description; - const billingCycleField = productFields.billingCycle; - const billingRaw = billingCycleField ? safeRecord[billingCycleField] : undefined; - if (typeof billingRaw === "string") { - base.billingCycle = billingRaw; - } + const billingCycle = getStringField(product, "billingCycle"); + if (billingCycle) base.billingCycle = billingCycle; - const displayOrderField = productFields.displayOrder; - const displayOrderRaw = displayOrderField ? safeRecord[displayOrderField] : undefined; - if (typeof displayOrderRaw === "number") { - base.displayOrder = displayOrderRaw; - } + const displayOrder = getProductField(product, "displayOrder"); + if (typeof displayOrder === "number") base.displayOrder = displayOrder; return base; } -function extractArray(raw: unknown): string[] | undefined { +function parseFeatureList(product: SalesforceCatalogProductRecord): string[] | undefined { + const raw = getProductField(product, "featureList"); if (Array.isArray(raw)) { - return raw.filter((value): value is string => typeof value === "string"); + return raw.filter((item): item is string => typeof item === "string"); } if (typeof raw === "string") { try { const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? extractArray(parsed) : undefined; + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : undefined; } catch { return undefined; } @@ -82,165 +112,125 @@ function extractArray(raw: unknown): string[] | undefined { return undefined; } -export function mapInternetProduct( - product: SalesforceProduct2Record, +function derivePrices( + product: SalesforceCatalogProductRecord, pricebookEntry?: SalesforcePricebookEntryRecord -): InternetCatalogProduct { - const base = baseProduct(product); - const tierField = productFields.internetPlanTier; - const offeringTypeField = productFields.internetOfferingType; - const tier = tierField ? (product as Record)[tierField] : undefined; - const offeringType = offeringTypeField - ? (product as Record)[offeringTypeField] - : undefined; +): Pick { + const billingCycle = getStringField(product, "billingCycle")?.toLowerCase(); + const unitPrice = pricebookEntry ? coerceNumber(pricebookEntry.UnitPrice) : undefined; - const rawFeatures = productFields.featureList - ? (product as Record)[productFields.featureList] - : undefined; + let monthlyPrice = undefined; + let oneTimePrice = undefined; - const features = extractArray(rawFeatures); + if (unitPrice !== undefined) { + if (billingCycle === "monthly") { + monthlyPrice = unitPrice; + } else if (billingCycle) { + oneTimePrice = unitPrice; + } + } - const monthlyPrice = getMonthlyPrice(product, pricebookEntry); - const oneTimePrice = getOneTimePrice(product, pricebookEntry); + if (monthlyPrice === undefined) { + const explicitMonthly = coerceNumber(getProductField(product, "monthlyPrice")); + if (explicitMonthly !== undefined) monthlyPrice = explicitMonthly; + } - return { - ...base, - internetPlanTier: typeof tier === "string" ? tier : undefined, - internetOfferingType: typeof offeringType === "string" ? offeringType : undefined, - features, - monthlyPrice, - oneTimePrice, - }; -} + if (oneTimePrice === undefined) { + const explicitOneTime = coerceNumber(getProductField(product, "oneTimePrice")); + if (explicitOneTime !== undefined) oneTimePrice = explicitOneTime; + } -const tierTemplates: Record = { - Silver: { - tierDescription: "Simple package with broadband-modem and ISP only", - description: "Simple package with broadband-modem and ISP only", - features: [ - "NTT modem + ISP connection", - "Two ISP connection protocols: IPoE (recommended) or PPPoE", - "Self-configuration of router (you provide your own)", - "Monthly: ¥6,000 | One-time: ¥22,800", - ], - }, - Gold: { - tierDescription: "Standard all-inclusive package with basic Wi-Fi", - description: "Standard all-inclusive package with basic Wi-Fi", - features: [ - "NTT modem + wireless router (rental)", - "ISP (IPoE) configured automatically within 24 hours", - "Basic wireless router included", - "Optional: TP-LINK RE650 range extender (¥500/month)", - "Monthly: ¥6,500 | One-time: ¥22,800", - ], - }, - Platinum: { - tierDescription: "Tailored set up with premier Wi-Fi management support", - description: - "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²", - features: [ - "NTT modem + Netgear INSIGHT Wi-Fi routers", - "Cloud management support for remote router management", - "Automatic updates and quicker support", - "Seamless wireless network setup", - "Monthly: ¥6,500 | One-time: ¥22,800", - "Cloud management: ¥500/month per router", - ], - }, -}; - -function getTierTemplate(tier?: string): InternetPlanTemplate { - if (!tier) return tierTemplates.Silver; - return tierTemplates[tier] ?? tierTemplates.Silver; + return { monthlyPrice, oneTimePrice }; } export function mapInternetPlan( - product: SalesforceProduct2Record, + product: SalesforceCatalogProductRecord, pricebookEntry?: SalesforcePricebookEntryRecord ): InternetPlanCatalogItem { - const mapped = mapInternetProduct(product, pricebookEntry); - const tierData = getTierTemplate(mapped.internetPlanTier); + const base = baseProduct(product); + const prices = derivePrices(product, pricebookEntry); + const tier = getStringField(product, "internetPlanTier"); + const offeringType = getStringField(product, "internetOfferingType"); + const features = parseFeatureList(product); + + const tierData = getTierTemplate(tier); return { - ...mapped, - description: mapped.description ?? tierData.description, + ...base, + ...prices, + internetPlanTier: tier, + internetOfferingType: offeringType, + features, catalogMetadata: { tierDescription: tierData.tierDescription, features: tierData.features, - isRecommended: mapped.internetPlanTier === "Gold", + isRecommended: tier === "Gold", }, + description: base.description ?? tierData.description, }; } export function mapInternetInstallation( - product: SalesforceProduct2Record, + product: SalesforceCatalogProductRecord, pricebookEntry?: SalesforcePricebookEntryRecord ): InternetInstallationCatalogItem { - const mapped = mapInternetProduct(product, pricebookEntry); + const base = baseProduct(product); + const prices = derivePrices(product, pricebookEntry); return { - ...mapped, + ...base, + ...prices, catalogMetadata: { - installationTerm: inferInstallationTypeFromSku(mapped.sku), + installationTerm: inferInstallationTypeFromSku(base.sku), }, - displayOrder: mapped.displayOrder, }; } export function mapInternetAddon( - product: SalesforceProduct2Record, + product: SalesforceCatalogProductRecord, pricebookEntry?: SalesforcePricebookEntryRecord ): InternetAddonCatalogItem { - const mapped = mapInternetProduct(product, pricebookEntry); + const base = baseProduct(product); + const prices = derivePrices(product, pricebookEntry); return { - ...mapped, + ...base, + ...prices, catalogMetadata: { - addonCategory: inferAddonTypeFromSku(mapped.sku), + addonCategory: inferAddonTypeFromSku(base.sku), autoAdd: false, requiredWith: [], }, - displayOrder: mapped.displayOrder, }; } export function mapSimProduct( - product: SalesforceProduct2Record, + product: SalesforceCatalogProductRecord, pricebookEntry?: SalesforcePricebookEntryRecord ): SimCatalogProduct { const base = baseProduct(product); - const dataSizeField = productFields.simDataSize; - const planTypeField = productFields.simPlanType; - const familyDiscountField = productFields.simHasFamilyDiscount; - - const dataSize = dataSizeField ? (product as Record)[dataSizeField] : undefined; - const planType = planTypeField ? (product as Record)[planTypeField] : undefined; - const familyDiscount = familyDiscountField - ? (product as Record)[familyDiscountField] - : undefined; - - const monthlyPrice = getMonthlyPrice(product, pricebookEntry); - const oneTimePrice = getOneTimePrice(product, pricebookEntry); + const prices = derivePrices(product, pricebookEntry); + const dataSize = getStringField(product, "simDataSize"); + const planType = getStringField(product, "simPlanType"); + const hasFamilyDiscount = getBooleanField(product, "simHasFamilyDiscount"); return { ...base, - simDataSize: typeof dataSize === "string" ? dataSize : undefined, - simPlanType: typeof planType === "string" ? planType : undefined, - simHasFamilyDiscount: typeof familyDiscount === "boolean" ? familyDiscount : undefined, - monthlyPrice, - oneTimePrice, + ...prices, + simDataSize: dataSize, + simPlanType: planType, + simHasFamilyDiscount: hasFamilyDiscount, }; } export function mapSimActivationFee( - product: SalesforceProduct2Record, + product: SalesforceCatalogProductRecord, pricebookEntry?: SalesforcePricebookEntryRecord ): SimActivationFeeCatalogItem { - const mapped = mapSimProduct(product, pricebookEntry); + const simProduct = mapSimProduct(product, pricebookEntry); return { - ...mapped, + ...simProduct, catalogMetadata: { isDefault: true, }, @@ -248,21 +238,16 @@ export function mapSimActivationFee( } export function mapVpnProduct( - product: SalesforceProduct2Record, + product: SalesforceCatalogProductRecord, pricebookEntry?: SalesforcePricebookEntryRecord ): VpnCatalogProduct { const base = baseProduct(product); - const regionField = productFields.vpnRegion; - const region = regionField ? (product as Record)[regionField] : undefined; - - const monthlyPrice = getMonthlyPrice(product, pricebookEntry); - const oneTimePrice = getOneTimePrice(product, pricebookEntry); + const prices = derivePrices(product, pricebookEntry); + const vpnRegion = getStringField(product, "vpnRegion"); return { ...base, - vpnRegion: typeof region === "string" ? region : undefined, - monthlyPrice, - oneTimePrice, + ...prices, + vpnRegion, }; } - diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts b/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts index 76b061ef..e69de29b 100644 --- a/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts +++ b/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts @@ -1,64 +0,0 @@ -import type { SalesforceProduct2Record, SalesforcePricebookEntryRecord } from "@customer-portal/domain"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; - -const fields = getSalesforceFieldMap().product; - -function coerceNumber(value: unknown): number | undefined { - if (typeof value === "number") return value; - if (typeof value === "string") { - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : undefined; - } - return undefined; -} - -export function getUnitPrice( - product: SalesforceProduct2Record, - pricebookEntry?: SalesforcePricebookEntryRecord -): number | undefined { - const entryPrice = coerceNumber(pricebookEntry?.UnitPrice); - if (entryPrice !== undefined) return entryPrice; - - const productPrice = coerceNumber((product as Record)[fields.unitPrice]); - if (productPrice !== undefined) return productPrice; - - const monthlyPrice = coerceNumber((product as Record)[fields.monthlyPrice]); - if (monthlyPrice !== undefined) return monthlyPrice; - - const oneTimePrice = coerceNumber((product as Record)[fields.oneTimePrice]); - if (oneTimePrice !== undefined) return oneTimePrice; - - return undefined; -} - -export function getMonthlyPrice( - product: SalesforceProduct2Record, - pricebookEntry?: SalesforcePricebookEntryRecord -): number | undefined { - const unitPrice = getUnitPrice(product, pricebookEntry); - if (!unitPrice) return undefined; - - const billingCycle = (product as Record)[fields.billingCycle]; - if (typeof billingCycle === "string" && billingCycle.toLowerCase() === "monthly") { - return unitPrice; - } - - const monthlyPrice = coerceNumber((product as Record)[fields.monthlyPrice]); - return monthlyPrice ?? undefined; -} - -export function getOneTimePrice( - product: SalesforceProduct2Record, - pricebookEntry?: SalesforcePricebookEntryRecord -): number | undefined { - const unitPrice = getUnitPrice(product, pricebookEntry); - const billingCycle = (product as Record)[fields.billingCycle]; - - if (typeof billingCycle === "string" && billingCycle.toLowerCase() !== "monthly") { - return unitPrice; - } - - const oneTimePrice = coerceNumber((product as Record)[fields.oneTimePrice]); - return oneTimePrice ?? undefined; -} - diff --git a/apps/bff/src/modules/health/health.controller.ts b/apps/bff/src/modules/health/health.controller.ts index d2dff481..1145405a 100644 --- a/apps/bff/src/modules/health/health.controller.ts +++ b/apps/bff/src/modules/health/health.controller.ts @@ -40,5 +40,3 @@ export class HealthController { return { status, checks }; } } - - diff --git a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts index efde9f5e..ce6cfde6 100644 --- a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts +++ b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts @@ -114,5 +114,3 @@ export class MappingCacheService { return `${this.CACHE_PREFIX}:${type}:${value}`; } } - - diff --git a/apps/bff/src/modules/id-mappings/mappings.module.ts b/apps/bff/src/modules/id-mappings/mappings.module.ts index 589d86e3..ecf17421 100644 --- a/apps/bff/src/modules/id-mappings/mappings.module.ts +++ b/apps/bff/src/modules/id-mappings/mappings.module.ts @@ -5,12 +5,8 @@ import { MappingValidatorService } from "./validation/mapping-validator.service" import { CacheModule } from "@bff/infra/cache/cache.module"; @Module({ - imports: [ - CacheModule, - ], + imports: [CacheModule], providers: [MappingsService, MappingCacheService, MappingValidatorService], exports: [MappingsService, MappingCacheService, MappingValidatorService], }) export class MappingsModule {} - - diff --git a/apps/bff/src/modules/id-mappings/mappings.service.ts b/apps/bff/src/modules/id-mappings/mappings.service.ts index 6d814668..3a470dc7 100644 --- a/apps/bff/src/modules/id-mappings/mappings.service.ts +++ b/apps/bff/src/modules/id-mappings/mappings.service.ts @@ -41,9 +41,13 @@ export class MappingsService { const [byUser, byWhmcs, bySf] = await Promise.all([ this.prisma.idMapping.findUnique({ where: { userId: sanitizedRequest.userId } }), - this.prisma.idMapping.findUnique({ where: { whmcsClientId: sanitizedRequest.whmcsClientId } }), + this.prisma.idMapping.findUnique({ + where: { whmcsClientId: sanitizedRequest.whmcsClientId }, + }), sanitizedRequest.sfAccountId - ? this.prisma.idMapping.findFirst({ where: { sfAccountId: sanitizedRequest.sfAccountId } }) + ? this.prisma.idMapping.findFirst({ + where: { sfAccountId: sanitizedRequest.sfAccountId }, + }) : Promise.resolve(null), ]); @@ -208,7 +212,10 @@ export class MappingsService { } const sanitizedUpdates = this.validator.sanitizeUpdateRequest(updates); - if (sanitizedUpdates.whmcsClientId && sanitizedUpdates.whmcsClientId !== existing.whmcsClientId) { + if ( + sanitizedUpdates.whmcsClientId && + sanitizedUpdates.whmcsClientId !== existing.whmcsClientId + ) { const conflictingMapping = await this.findByWhmcsClientId(sanitizedUpdates.whmcsClientId); if (conflictingMapping && conflictingMapping.userId !== userId) { throw new ConflictException( @@ -217,7 +224,10 @@ export class MappingsService { } } - const updated = await this.prisma.idMapping.update({ where: { userId }, data: sanitizedUpdates }); + const updated = await this.prisma.idMapping.update({ + where: { userId }, + data: sanitizedUpdates, + }); const newMapping = this.toDomain(updated); @@ -274,7 +284,10 @@ export class MappingsService { whereClause.sfAccountId = filters.hasSfMapping ? { not: null } : null; } - const dbMappings = await this.prisma.idMapping.findMany({ where: whereClause, orderBy: { createdAt: "desc" } }); + const dbMappings = await this.prisma.idMapping.findMany({ + where: whereClause, + orderBy: { createdAt: "desc" }, + }); const mappings = dbMappings.map(mapping => this.toDomain(mapping)); this.logger.debug(`Found ${mappings.length} mappings matching filters`, filters); return mappings; @@ -312,10 +325,15 @@ export class MappingsService { try { const cached = await this.cacheService.getByUserId(userId); if (cached) return true; - const mapping = await this.prisma.idMapping.findUnique({ where: { userId }, select: { userId: true } }); + const mapping = await this.prisma.idMapping.findUnique({ + where: { userId }, + select: { userId: true }, + }); return mapping !== null; } catch (error) { - this.logger.error(`Failed to check mapping for user ${userId}`, { error: getErrorMessage(error) }); + this.logger.error(`Failed to check mapping for user ${userId}`, { + error: getErrorMessage(error), + }); return false; } } diff --git a/apps/bff/src/modules/id-mappings/types/mapping.types.ts b/apps/bff/src/modules/id-mappings/types/mapping.types.ts index 05dcf66c..a8780822 100644 --- a/apps/bff/src/modules/id-mappings/types/mapping.types.ts +++ b/apps/bff/src/modules/id-mappings/types/mapping.types.ts @@ -5,11 +5,7 @@ import type { UpdateMappingRequest, } from "../validation/mapping-validator.service"; -export type { - UserIdMapping, - CreateMappingRequest, - UpdateMappingRequest, -}; +export type { UserIdMapping, CreateMappingRequest, UpdateMappingRequest }; export interface MappingSearchFilters { userId?: string; @@ -59,5 +55,3 @@ export interface CachedMapping { cachedAt: Date; ttl: number; } - - diff --git a/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts b/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts index 2b1bc3aa..d85bb675 100644 --- a/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts +++ b/apps/bff/src/modules/id-mappings/validation/mapping-validator.service.ts @@ -6,12 +6,12 @@ import { z } from "zod"; const createMappingRequestSchema = z.object({ userId: z.string().uuid(), whmcsClientId: z.number().int().positive(), - sfAccountId: z.string().optional() + sfAccountId: z.string().optional(), }); const updateMappingRequestSchema = z.object({ whmcsClientId: z.number().int().positive().optional(), - sfAccountId: z.string().optional() + sfAccountId: z.string().optional(), }); const userIdMappingSchema = z.object({ @@ -20,7 +20,7 @@ const userIdMappingSchema = z.object({ whmcsClientId: z.number().int().positive(), sfAccountId: z.string().nullable(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), }); export type CreateMappingRequest = z.infer; @@ -40,7 +40,7 @@ export class MappingValidatorService { validateCreateRequest(request: CreateMappingRequest): MappingValidationResult { const validationResult = createMappingRequestSchema.safeParse(request); - + if (validationResult.success) { const warnings: string[] = []; if (!request.sfAccountId) { @@ -51,7 +51,7 @@ export class MappingValidatorService { const errors = validationResult.error.issues.map(issue => issue.message); this.logger.warn({ request, errors }, "Create mapping request validation failed"); - + return { isValid: false, errors, warnings: [] }; } @@ -62,26 +62,26 @@ export class MappingValidatorService { return { isValid: false, errors: ["User ID must be a valid UUID"], - warnings: [] + warnings: [], }; } // Then validate the update request const validationResult = updateMappingRequestSchema.safeParse(request); - + if (validationResult.success) { return { isValid: true, errors: [], warnings: [] }; } const errors = validationResult.error.issues.map(issue => issue.message); this.logger.warn({ userId, request, errors }, "Update mapping request validation failed"); - + return { isValid: false, errors, warnings: [] }; } validateExistingMapping(mapping: UserIdMapping): MappingValidationResult { const validationResult = userIdMappingSchema.safeParse(mapping); - + if (validationResult.success) { const warnings: string[] = []; if (!mapping.sfAccountId) { @@ -92,18 +92,23 @@ export class MappingValidatorService { const errors = validationResult.error.issues.map(issue => issue.message); this.logger.warn({ mapping, errors }, "Existing mapping validation failed"); - + return { isValid: false, errors, warnings: [] }; } - validateBulkMappings(mappings: CreateMappingRequest[]): Array<{ index: number; validation: MappingValidationResult }> { - return mappings.map((mapping, index) => ({ - index, - validation: this.validateCreateRequest(mapping) + validateBulkMappings( + mappings: CreateMappingRequest[] + ): Array<{ index: number; validation: MappingValidationResult }> { + return mappings.map((mapping, index) => ({ + index, + validation: this.validateCreateRequest(mapping), })); } - validateNoConflicts(request: CreateMappingRequest, existingMappings: UserIdMapping[]): MappingValidationResult { + validateNoConflicts( + request: CreateMappingRequest, + existingMappings: UserIdMapping[] + ): MappingValidationResult { const errors: string[] = []; const warnings: string[] = []; @@ -121,13 +126,17 @@ export class MappingValidatorService { const duplicateWhmcs = existingMappings.find(m => m.whmcsClientId === request.whmcsClientId); if (duplicateWhmcs) { - errors.push(`WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}`); + errors.push( + `WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}` + ); } if (request.sfAccountId) { const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId); if (duplicateSf) { - warnings.push(`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`); + warnings.push( + `Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}` + ); } } @@ -149,9 +158,13 @@ export class MappingValidatorService { return formatValidation; } - warnings.push("Deleting this mapping will prevent access to WHMCS/Salesforce data for this user"); + warnings.push( + "Deleting this mapping will prevent access to WHMCS/Salesforce data for this user" + ); if (mapping.sfAccountId) { - warnings.push("This mapping includes Salesforce integration - deletion will affect case management"); + warnings.push( + "This mapping includes Salesforce integration - deletion will affect case management" + ); } return { isValid: true, errors, warnings }; @@ -179,18 +192,18 @@ export class MappingValidatorService { sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest { const sanitized: any = {}; - + if (request.whmcsClientId !== undefined) { sanitized.whmcsClientId = request.whmcsClientId; } - + if (request.sfAccountId !== undefined) { sanitized.sfAccountId = request.sfAccountId?.trim() || undefined; } // Use Zod parsing to validate the sanitized data const validationResult = updateMappingRequestSchema.safeParse(sanitized); - + if (validationResult.success) { return validationResult.data; } @@ -198,4 +211,4 @@ export class MappingValidatorService { // Fallback to sanitized data if validation fails return sanitized; } -} \ No newline at end of file +} diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index d9f58c1f..fb691ba0 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -4,10 +4,7 @@ import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@ne import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import { Logger } from "nestjs-pino"; import { ZodValidationPipe } from "@bff/core/validation"; -import { - createOrderRequestSchema, - type CreateOrderRequest -} from "@customer-portal/domain"; +import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain"; @ApiTags("orders") @Controller("orders") diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index a0ca1517..3ea70c2e 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -8,6 +8,7 @@ import { UsersModule } from "@bff/modules/users/users.module"; import { OrderValidator } from "./services/order-validator.service"; import { OrderBuilder } from "./services/order-builder.service"; import { OrderItemBuilder } from "./services/order-item-builder.service"; +import { OrderPricebookService } from "./services/order-pricebook.service"; import { OrderOrchestrator } from "./services/order-orchestrator.service"; // Clean modular fulfillment services @@ -27,6 +28,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor"; OrderValidator, OrderBuilder, OrderItemBuilder, + OrderPricebookService, OrderOrchestrator, // Order fulfillment services (modular) 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 61d71bb9..386361f0 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -4,9 +4,26 @@ import type { OrderBusinessValidation, UserMapping } from "@customer-portal/doma import { getSalesforceFieldMap } from "@bff/core/config/field-map"; import { UsersService } from "@bff/modules/users/users.service"; -/** - * Handles building order header data from selections - */ +const fieldMap = getSalesforceFieldMap(); + +function assignIfString(target: Record, key: string, value: unknown): void { + if (typeof value === "string" && value.trim().length > 0) { + target[key] = value; + } +} + +function orderField(key: keyof typeof fieldMap.order): string { + return fieldMap.order[key]; +} + +function mnpField(key: keyof typeof fieldMap.order.mnp): string { + return fieldMap.order.mnp[key]; +} + +function billingField(key: keyof typeof fieldMap.order.billing): string { + return fieldMap.order.billing[key]; +} + @Injectable() export class OrderBuilder { constructor( @@ -14,31 +31,25 @@ export class OrderBuilder { private readonly usersService: UsersService ) {} - /** - * Build order fields for Salesforce Order creation - */ async buildOrderFields( body: OrderBusinessValidation, userMapping: UserMapping, pricebookId: string, userId: string ): Promise> { - const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD - const fields = getSalesforceFieldMap(); + const today = new Date().toISOString().slice(0, 10); const orderFields: Record = { AccountId: userMapping.sfAccountId, EffectiveDate: today, Status: "Pending Review", Pricebook2Id: pricebookId, - [fields.order.orderType]: body.orderType, + [orderField("orderType")]: body.orderType, ...(body.opportunityId ? { OpportunityId: body.opportunityId } : {}), }; - // Add activation fields this.addActivationFields(orderFields, body); - // Add service-specific fields (only user configuration choices) switch (body.orderType) { case "Internet": this.addInternetFields(orderFields, body); @@ -51,139 +62,94 @@ export class OrderBuilder { break; } - // Add address snapshot (single address for both billing and shipping) await this.addAddressSnapshot(orderFields, userId, body); return orderFields; } - private addActivationFields(orderFields: Record, body: OrderBusinessValidation): void { - const fields = getSalesforceFieldMap(); + private addActivationFields( + orderFields: Record, + body: OrderBusinessValidation + ): void { const config = body.configurations || {}; - if (config.activationType) { - orderFields[fields.order.activationType] = config.activationType; - } - if (config.scheduledAt) { - orderFields[fields.order.activationScheduledAt] = config.scheduledAt; - } - orderFields[fields.order.activationStatus] = "Not Started"; + assignIfString(orderFields, orderField("activationType"), config.activationType); + assignIfString(orderFields, orderField("activationScheduledAt"), config.scheduledAt); + orderFields[orderField("activationStatus")] = "Not Started"; } - private addInternetFields(orderFields: Record, body: OrderBusinessValidation): void { - const fields = getSalesforceFieldMap(); + private addInternetFields( + orderFields: Record, + body: OrderBusinessValidation + ): void { const config = body.configurations || {}; - - // Only store user configuration choices that cannot be derived from OrderItems - if (config.accessMode) { - orderFields[fields.order.accessMode] = config.accessMode; - } - - // Note: Removed fields that can be derived from OrderItems: - // - internetPlanTier: derive from service product metadata - // - installationType: derive from install product name - // - weekendInstall: derive from SKU analysis - // - hikariDenwa: derive from SKU analysis + assignIfString(orderFields, orderField("accessMode"), config.accessMode); } private addSimFields(orderFields: Record, body: OrderBusinessValidation): void { - const fields = getSalesforceFieldMap(); const config = body.configurations || {}; + assignIfString(orderFields, orderField("simType"), config.simType); + assignIfString(orderFields, orderField("eid"), config.eid); - // Only store user configuration choices that cannot be derived from OrderItems - if (config.simType) { - orderFields[fields.order.simType] = config.simType; - } - if (config.eid) { - orderFields[fields.order.eid] = config.eid; - } - - // Note: Removed fields that can be derived from OrderItems: - // - simVoiceMail: derive from SKU analysis - // - simCallWaiting: derive from SKU analysis - - // MNP fields if (config.isMnp === "true") { - orderFields[fields.order.mnp.application] = true; - if (config.mnpNumber) { - orderFields[fields.order.mnp.reservationNumber] = config.mnpNumber; - } - if (config.mnpExpiry) { - orderFields[fields.order.mnp.expiryDate] = config.mnpExpiry; - } - if (config.mnpPhone) { - orderFields[fields.order.mnp.phoneNumber] = config.mnpPhone; - } - if (config.mvnoAccountNumber) { - orderFields[fields.order.mnp.mvnoAccountNumber] = config.mvnoAccountNumber; - } - if (config.portingLastName) { - orderFields[fields.order.mnp.portingLastName] = config.portingLastName; - } - if (config.portingFirstName) { - orderFields[fields.order.mnp.portingFirstName] = config.portingFirstName; - } - if (config.portingLastNameKatakana) { - orderFields[fields.order.mnp.portingLastNameKatakana] = config.portingLastNameKatakana; - } - if (config.portingFirstNameKatakana) { - orderFields[fields.order.mnp.portingFirstNameKatakana] = config.portingFirstNameKatakana; - } - if (config.portingGender) { - orderFields[fields.order.mnp.portingGender] = config.portingGender; - } - if (config.portingDateOfBirth) { - orderFields[fields.order.mnp.portingDateOfBirth] = config.portingDateOfBirth; - } + orderFields[mnpField("application")] = true; + assignIfString(orderFields, mnpField("reservationNumber"), config.mnpNumber); + assignIfString(orderFields, mnpField("expiryDate"), config.mnpExpiry); + assignIfString(orderFields, mnpField("phoneNumber"), config.mnpPhone); + assignIfString(orderFields, mnpField("mvnoAccountNumber"), config.mvnoAccountNumber); + assignIfString(orderFields, mnpField("portingLastName"), config.portingLastName); + assignIfString(orderFields, mnpField("portingFirstName"), config.portingFirstName); + assignIfString( + orderFields, + mnpField("portingLastNameKatakana"), + config.portingLastNameKatakana + ); + assignIfString( + orderFields, + mnpField("portingFirstNameKatakana"), + config.portingFirstNameKatakana + ); + assignIfString(orderFields, mnpField("portingGender"), config.portingGender); + assignIfString(orderFields, mnpField("portingDateOfBirth"), config.portingDateOfBirth); } } - private addVpnFields(_orderFields: Record, _body: OrderBusinessValidation): void { - // Note: Removed vpnRegion field - can be derived from service product metadata in OrderItems - // VPN orders only need user configuration choices (none currently defined) + private addVpnFields( + _orderFields: Record, + _body: OrderBusinessValidation + ): void { + // No additional fields for VPN orders at this time. } - /** - * Add address snapshot to order - * Always captures current address in billing fields and flags if changed - */ private async addAddressSnapshot( orderFields: Record, userId: string, body: OrderBusinessValidation ): Promise { try { - const fields = getSalesforceFieldMap(); const address = await this.usersService.getAddress(userId); - - // Check if address was provided/updated in the order request const orderAddress = (body.configurations as Record)?.address as | Record | undefined; const addressChanged = !!orderAddress; - - // Use order address if provided, otherwise use current WHMCS address const addressToUse = orderAddress || address; - // Always populate billing address fields (even if empty) - // Combine street and streetLine2 for Salesforce BillToStreet field const street = typeof addressToUse?.street === "string" ? addressToUse.street : ""; const streetLine2 = typeof addressToUse?.streetLine2 === "string" ? addressToUse.streetLine2 : ""; const fullStreet = [street, streetLine2].filter(Boolean).join(", "); - orderFields[fields.order.billing.street] = fullStreet || ""; - orderFields[fields.order.billing.city] = + orderFields[billingField("street")] = fullStreet; + orderFields[billingField("city")] = typeof addressToUse?.city === "string" ? addressToUse.city : ""; - orderFields[fields.order.billing.state] = + orderFields[billingField("state")] = typeof addressToUse?.state === "string" ? addressToUse.state : ""; - orderFields[fields.order.billing.postalCode] = + orderFields[billingField("postalCode")] = typeof addressToUse?.postalCode === "string" ? addressToUse.postalCode : ""; - orderFields[fields.order.billing.country] = + orderFields[billingField("country")] = typeof addressToUse?.country === "string" ? addressToUse.country : ""; - // Set Address_Changed flag if customer updated address during checkout - orderFields[fields.order.addressChanged] = addressChanged; + orderFields[orderField("addressChanged")] = addressChanged; if (addressChanged) { this.logger.log({ userId }, "Customer updated address during checkout"); @@ -198,8 +164,13 @@ export class OrderBuilder { "Address snapshot added to order" ); } catch (error) { - this.logger.error({ userId, error }, "Failed to add address snapshot to order"); - // Don't fail the order creation, but log the issue + this.logger.warn( + { + userId, + error: error instanceof Error ? error.message : String(error), + }, + "Failed to add address snapshot" + ); } } } 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 d1e90aad..c8bf3969 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 @@ -1,7 +1,10 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; -import { WhmcsOrderService, WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service"; +import { + WhmcsOrderService, + WhmcsOrderResult, +} from "@bff/integrations/whmcs/services/whmcs-order.service"; import { OrderOrchestrator } from "./order-orchestrator.service"; import { OrderFulfillmentValidator, @@ -12,6 +15,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service" import { SimFulfillmentService } from "./sim-fulfillment.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import type { OrderDetailsResponse } from "@customer-portal/domain"; export interface OrderFulfillmentStep { step: string; @@ -21,13 +25,11 @@ export interface OrderFulfillmentStep { error?: string; } -import { OrderDetailsDto } from "../types/order-details.dto"; - export interface OrderFulfillmentContext { sfOrderId: string; idempotencyKey: string; validation: OrderFulfillmentValidationResult; - orderDetails?: OrderDetailsDto; // Transformed order with items + orderDetails?: OrderDetailsResponse; mappingResult?: OrderItemMappingResult; whmcsResult?: WhmcsOrderResult; steps: OrderFulfillmentStep[]; diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index be53eb90..7931eaa2 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -6,12 +6,8 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SalesforceOrderRecord } from "@customer-portal/domain"; import { getSalesforceFieldMap } from "@bff/core/config/field-map"; -import { - userMappingValidationSchema, - paymentMethodValidationSchema, - type UserMappingValidation, - type PaymentMethodValidation -} from "@customer-portal/domain"; + +const fieldMap = getSalesforceFieldMap(); export interface OrderFulfillmentValidationResult { sfOrder: SalesforceOrderRecord; @@ -51,8 +47,7 @@ export class OrderFulfillmentValidator { const sfOrder = await this.validateSalesforceOrder(sfOrderId); // 2. Check if already provisioned (idempotency) - const fields = getSalesforceFieldMap(); - const rawWhmcs = (sfOrder as unknown as Record)[fields.order.whmcsOrderId]; + const rawWhmcs = (sfOrder as Record)[fieldMap.order.whmcsOrderId]; const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined; if (existingWhmcsOrderId) { this.logger.log("Order already provisioned", { @@ -69,8 +64,8 @@ export class OrderFulfillmentValidator { } // 3. Get WHMCS client mapping - const accountId = (sfOrder as unknown as { AccountId?: unknown })?.AccountId; - if (typeof accountId !== "string" || !accountId) { + const accountId = sfOrder.AccountId; + if (typeof accountId !== "string" || accountId.length === 0) { throw new BadRequestException("Salesforce order is missing AccountId"); } const mapping = await this.mappingsService.findBySfAccountId(accountId); @@ -113,40 +108,18 @@ export class OrderFulfillmentValidator { throw new BadRequestException(`Salesforce order ${sfOrderId} not found`); } - // Cast to SalesforceOrder for type safety - const salesforceOrder = order as unknown as SalesforceOrder; - - // Validate order is in a state that can be provisioned - if (salesforceOrder.Status === "Cancelled") { + if (order.Status === "Cancelled") { throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`); } - const fields = getSalesforceFieldMap(); this.logger.log("Salesforce order validated", { sfOrderId, - status: salesforceOrder.Status, - activationStatus: ((): string | undefined => { - const v = (salesforceOrder as unknown as Record)[ - fields.order.activationStatus - ]; - return typeof v === "string" ? v : undefined; - })(), - accountId: (salesforceOrder as unknown as { AccountId?: unknown })?.AccountId, + status: order.Status, + activationStatus: pickOrderString(order, "activationStatus"), + accountId: order.AccountId, }); - return salesforceOrder; - } - - /** - * Get WHMCS client ID from Salesforce account ID using mappings - */ - private async getWhmcsClientId(sfAccountId: string): Promise { - // Deprecated: retained for compatibility if referenced elsewhere - const mapping = await this.mappingsService.findBySfAccountId(sfAccountId); - if (!mapping?.whmcsClientId) { - throw new BadRequestException(`No WHMCS client mapping found for account ${sfAccountId}`); - } - return mapping.whmcsClientId; + return order; } /** @@ -181,3 +154,11 @@ export class OrderFulfillmentValidator { } } } + +function pickOrderString( + order: SalesforceOrderRecord, + key: keyof typeof fieldMap.order +): string | undefined { + const raw = (order as Record)[fieldMap.order[key]]; + return typeof raw === "string" ? raw : undefined; +} 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 abfceea0..2b25213b 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 @@ -1,12 +1,7 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; -import type { - SalesforcePricebookEntryRecord, - SalesforceProduct2Record, - SalesforceQueryResult, -} from "@customer-portal/domain"; +import { OrderPricebookService } from "./order-pricebook.service"; /** * Handles building order items from SKU data @@ -15,7 +10,8 @@ import type { export class OrderItemBuilder { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly sf: SalesforceConnection + private readonly sf: SalesforceConnection, + private readonly pricebookService: OrderPricebookService ) {} /** @@ -32,24 +28,33 @@ export class OrderItemBuilder { this.logger.log({ orderId, skus }, "Creating OrderItems from SKU array"); + const metaMap = await this.pricebookService.fetchProductMeta(pricebookId, skus); + // Create OrderItems for each SKU for (const sku of skus) { - const meta = await this.getProductMetaBySku(pricebookId, sku); + const normalizedSkuValue = sku?.trim(); + if (!normalizedSkuValue) { + this.logger.error({ orderId }, "Encountered empty SKU while creating order items"); + throw new BadRequestException("Product SKU is required"); + } - if (!meta?.pbeId) { + const normalizedSku = normalizedSkuValue.toUpperCase(); + const meta = metaMap.get(normalizedSku); + + if (!meta?.pricebookEntryId) { this.logger.error({ sku }, "PricebookEntry not found for SKU"); throw new NotFoundException(`Product not found: ${sku}`); } if (!meta.unitPrice) { - this.logger.error({ sku, pbeId: meta.pbeId }, "PricebookEntry missing UnitPrice"); - throw new Error(`PricebookEntry for SKU ${sku} has no UnitPrice set`); + this.logger.error({ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId }, "PricebookEntry missing UnitPrice"); + throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`); } this.logger.log( { - sku, - pbeId: meta.pbeId, + sku: normalizedSkuValue, + pbeId: meta.pricebookEntryId, unitPrice: meta.unitPrice, }, "Creating OrderItem" @@ -59,127 +64,19 @@ export class OrderItemBuilder { // Salesforce requires explicit UnitPrice even with PricebookEntryId await this.sf.sobject("OrderItem").create({ OrderId: orderId, - PricebookEntryId: meta.pbeId, + PricebookEntryId: meta.pricebookEntryId, Quantity: 1, UnitPrice: meta.unitPrice, }); - this.logger.log({ orderId, sku }, "OrderItem created successfully"); + this.logger.log({ orderId, sku: normalizedSkuValue }, "OrderItem created successfully"); } catch (error) { - this.logger.error({ error, orderId, sku }, "Failed to create OrderItem"); + this.logger.error({ error, orderId, sku: normalizedSkuValue }, "Failed to create OrderItem"); throw error; } } } - /** - * Find Portal pricebook ID - */ - async findPortalPricebookId(): Promise { - const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal"; - const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${name}%' LIMIT 1`; - - try { - const result = (await this.sf.query(soql)) as SalesforceQueryResult<{ Id?: string }>; - if (result.records?.length) { - return result.records[0]?.Id ?? ""; - } - - const std = (await this.sf.query( - "SELECT Id FROM Pricebook2 WHERE IsStandard = true AND IsActive = true LIMIT 1" - )) as SalesforceQueryResult<{ Id?: string }>; - - return std.records?.[0]?.Id ?? ""; - } catch (error) { - this.logger.error({ error }, "Failed to find pricebook"); - throw new NotFoundException("Portal pricebook not found or inactive"); - } - } - - /** - * Get product metadata by SKU - */ - async getProductMetaBySku( - pricebookId: string, - sku: string - ): Promise<{ - pbeId: string; - product2Id: string; - unitPrice?: number; - itemClass?: string; - internetOfferingType?: string; - internetPlanTier?: string; - vpnRegion?: string; - } | null> { - if (!sku) return null; - - const fields = getSalesforceFieldMap(); - const safeSku = sku.replace(/'/g, "\\'"); - - const soql = - `SELECT Id, Product2Id, UnitPrice, ` + - `Product2.${fields.product.itemClass}, ` + - `Product2.${fields.product.internetOfferingType}, ` + - `Product2.${fields.product.internetPlanTier}, ` + - `Product2.${fields.product.vpnRegion} ` + - `FROM PricebookEntry ` + - `WHERE Pricebook2Id='${pricebookId}' AND IsActive=true AND Product2.${fields.product.sku}='${safeSku}' ` + - `LIMIT 1`; - - try { - this.logger.debug({ sku, pricebookId }, "Querying PricebookEntry for SKU"); - - const res = (await this.sf.query(soql)) as SalesforceQueryResult< - SalesforcePricebookEntryRecord & { - Product2?: SalesforceProduct2Record | null; - } - >; - - this.logger.debug( - { - sku, - found: !!res.records?.length, - hasPrice: res.records?.[0]?.UnitPrice != null, - }, - "PricebookEntry query result" - ); - - const rec = res.records?.[0]; - if (!rec?.Id) return null; - - const product2 = rec.Product2 ?? null; - - return { - pbeId: rec.Id, - product2Id: rec.Product2Id ?? "", - unitPrice: typeof rec.UnitPrice === "number" ? rec.UnitPrice : undefined, - itemClass: (() => { - const value = product2 ? (product2 as Record)[fields.product.itemClass] : undefined; - return typeof value === "string" ? value : undefined; - })(), - internetOfferingType: (() => { - const value = product2 - ? (product2 as Record)[fields.product.internetOfferingType] - : undefined; - return typeof value === "string" ? value : undefined; - })(), - internetPlanTier: (() => { - const value = product2 - ? (product2 as Record)[fields.product.internetPlanTier] - : undefined; - return typeof value === "string" ? value : undefined; - })(), - vpnRegion: (() => { - const value = product2 ? (product2 as Record)[fields.product.vpnRegion] : undefined; - return typeof value === "string" ? value : undefined; - })(), - }; - } catch (error) { - this.logger.error({ error, sku }, "Failed to get product metadata"); - return null; - } - } - /** * Get service product metadata from SKU array for order header defaults */ @@ -196,11 +93,29 @@ export class OrderItemBuilder { // Find installation SKU const installSku = skus.find(sku => sku.toUpperCase().includes("INSTALL")); - const [serviceProduct, installProduct] = await Promise.all([ - serviceSku ? this.getProductMetaBySku(pricebookId, serviceSku) : null, - installSku ? this.getProductMetaBySku(pricebookId, installSku) : null, - ]); + const targets = [serviceSku, installSku].filter( + (sku): sku is string => typeof sku === "string" && sku.length > 0 + ); + const metaMap = await this.pricebookService.fetchProductMeta(pricebookId, targets); - return { serviceProduct, installProduct }; + const mapMeta = (sku?: string | null) => { + if (!sku) return null; + const entry = metaMap.get(sku.trim().toUpperCase()); + if (!entry) return null; + return { + pbeId: entry.pricebookEntryId, + product2Id: entry.product2Id ?? "", + unitPrice: entry.unitPrice, + itemClass: entry.itemClass, + internetOfferingType: entry.internetOfferingType, + internetPlanTier: entry.internetPlanTier, + vpnRegion: entry.vpnRegion, + }; + }; + + return { + serviceProduct: mapMeta(serviceSku), + installProduct: mapMeta(installSku), + }; } } 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 8da4eefa..14804c2d 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -4,24 +4,98 @@ import { SalesforceConnection } from "@bff/integrations/salesforce/services/sale import { OrderValidator } from "./order-validator.service"; import { OrderBuilder } from "./order-builder.service"; import { OrderItemBuilder } from "./order-item-builder.service"; -import type { - SalesforceOrderRecord, - SalesforceOrderItemRecord, - SalesforceProduct2Record, - SalesforceQueryResult, -} from "@customer-portal/domain"; import { orderDetailsSchema, orderSummarySchema, type OrderDetailsResponse, type OrderSummaryResponse, -} from "@customer-portal/domain/validation/api/responses"; + type OrderItemSummary, + type SalesforceOrderRecord, + type SalesforceOrderItemRecord, + type SalesforceQueryResult, +} from "@customer-portal/domain"; import { getSalesforceFieldMap, getOrderQueryFields, getOrderItemProduct2Select, } from "@bff/core/config/field-map"; -import type { SalesforceFieldMap } from "@bff/core/config/field-map"; +import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/utils/soql.util"; + +const fieldMap = getSalesforceFieldMap(); + +function getOrderStringField( + order: SalesforceOrderRecord, + key: keyof typeof fieldMap.order +): string | undefined { + const raw = (order as Record)[fieldMap.order[key]]; + return typeof raw === "string" ? raw : undefined; +} + +function pickProductString( + product: Record | undefined, + key: keyof typeof fieldMap.product +): string | undefined { + if (!product) return undefined; + const raw = product[fieldMap.product[key]]; + return typeof raw === "string" ? raw : undefined; +} + +function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemDetails { + const product = record.PricebookEntry?.Product2 as Record | undefined; + + return { + id: record.Id ?? "", + orderId: record.OrderId ?? "", + quantity: record.Quantity ?? 0, + unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined, + totalPrice: typeof record.TotalPrice === "number" ? record.TotalPrice : undefined, + billingCycle: typeof record.Billing_Cycle__c === "string" ? record.Billing_Cycle__c : undefined, + product: { + id: product?.Id, + name: product?.Name, + sku: pickProductString(product, "sku"), + itemClass: pickProductString(product, "itemClass"), + whmcsProductId: pickProductString(product, "whmcsProductId"), + internetOfferingType: pickProductString(product, "internetOfferingType"), + internetPlanTier: pickProductString(product, "internetPlanTier"), + vpnRegion: pickProductString(product, "vpnRegion"), + }, + }; +} + +function toOrderItemSummary(details: ParsedOrderItemDetails): OrderItemSummary { + return { + orderId: details.orderId, + product: { + name: details.product.name, + sku: details.product.sku, + itemClass: details.product.itemClass, + }, + quantity: details.quantity, + unitPrice: details.unitPrice, + totalPrice: details.totalPrice, + billingCycle: details.billingCycle, + }; +} + +interface ParsedOrderItemDetails { + id: string; + orderId: string; + quantity: number; + unitPrice?: number; + totalPrice?: number; + billingCycle?: string; + product: { + id?: string; + name?: string; + sku?: string; + itemClass?: string; + whmcsProductId?: string; + internetOfferingType?: string; + internetPlanTier?: string; + vpnRegion?: string; + }; +} /** * Main orchestrator for order operations @@ -57,12 +131,11 @@ export class OrderOrchestrator { ); // 2) Build order fields (includes address snapshot) - const orderFieldsInput = { ...validatedBody, userId }; const orderFields = await this.orderBuilder.buildOrderFields( - orderFieldsInput, + validatedBody, userMapping, pricebookId, - userId + validatedBody.userId ); this.logger.log({ orderType: orderFields.Type }, "Creating Salesforce Order"); @@ -109,14 +182,14 @@ export class OrderOrchestrator { * Get order by ID with order items */ async getOrder(orderId: string): Promise { - this.logger.log({ orderId }, "Fetching order details with items"); + const safeOrderId = assertSalesforceId(orderId, "orderId"); + this.logger.log({ orderId: safeOrderId }, "Fetching order details with items"); - const fields = getSalesforceFieldMap(); const orderSoql = ` SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount, Account.Name, CreatedDate, LastModifiedDate FROM Order - WHERE Id = '${orderId}' + WHERE Id = '${safeOrderId}' LIMIT 1 `; @@ -125,16 +198,14 @@ export class OrderOrchestrator { PricebookEntry.Id, ${getOrderItemProduct2Select()} FROM OrderItem - WHERE OrderId = '${orderId}' + WHERE OrderId = '${safeOrderId}' ORDER BY CreatedDate ASC `; try { const [orderResult, itemsResult] = await Promise.all([ this.sf.query(orderSoql) as Promise>, - this.sf.query(orderItemsSoql) as Promise< - SalesforceQueryResult - >, + this.sf.query(orderItemsSoql) as Promise>, ]); const order = orderResult.records?.[0]; @@ -144,35 +215,46 @@ export class OrderOrchestrator { return null; } - const orderItems = (itemsResult.records ?? []).map(item => - mapOrderItemForDetails(item, fields) - ); + const orderItems = (itemsResult.records ?? []).map(mapOrderItemRecord); this.logger.log( - { orderId, itemCount: orderItems.length }, + { orderId: safeOrderId, itemCount: orderItems.length }, "Order details retrieved with items" ); - // Transform raw Salesforce data to domain types - const domainOrder = order; - const domainOrderItems = orderItems.map(item => mapOrderItemForSummary(item, fields)); - return orderDetailsSchema.parse({ - id: domainOrder.Id, - orderNumber: domainOrder.OrderNumber, - status: domainOrder.Status, - accountId: domainOrder.AccountId, - orderType: (domainOrder as any).OrderType || (domainOrder as any).Type, - effectiveDate: domainOrder.EffectiveDate, - totalAmount: domainOrder.TotalAmount, + id: order.Id, + orderNumber: order.OrderNumber, + status: order.Status, + accountId: order.AccountId, + orderType: getOrderStringField(order, "orderType") ?? order.Type, + effectiveDate: order.EffectiveDate, + totalAmount: order.TotalAmount ?? 0, accountName: order.Account?.Name, - createdDate: domainOrder.CreatedDate, - lastModifiedDate: domainOrder.LastModifiedDate, - activationType: (order as any)[fields.order.activationType], - activationStatus: (order as any)[fields.order.activationStatus], - scheduledAt: (order as any)[fields.order.activationScheduledAt], - whmcsOrderId: (order as any)[fields.order.whmcsOrderId], - items: domainOrderItems, + createdDate: order.CreatedDate, + lastModifiedDate: order.LastModifiedDate, + activationType: getOrderStringField(order, "activationType"), + activationStatus: getOrderStringField(order, "activationStatus"), + scheduledAt: getOrderStringField(order, "activationScheduledAt"), + whmcsOrderId: getOrderStringField(order, "whmcsOrderId"), + items: orderItems.map(detail => ({ + id: detail.id, + orderId: detail.orderId, + quantity: detail.quantity, + unitPrice: detail.unitPrice, + totalPrice: detail.totalPrice, + billingCycle: detail.billingCycle, + product: { + id: detail.product.id, + name: detail.product.name, + sku: detail.product.sku, + itemClass: detail.product.itemClass, + whmcsProductId: detail.product.whmcsProductId, + internetOfferingType: detail.product.internetOfferingType, + internetPlanTier: detail.product.internetPlanTier, + vpnRegion: detail.product.vpnRegion, + }, + })), }); } catch (error) { this.logger.error({ error, orderId }, "Failed to fetch order with items"); @@ -188,20 +270,26 @@ export class OrderOrchestrator { // Get user mapping const userMapping = await this.orderValidator.validateUserMapping(userId); + const sfAccountId = userMapping.sfAccountId + ? assertSalesforceId(userMapping.sfAccountId, "sfAccountId") + : undefined; + if (!sfAccountId) { + this.logger.warn({ userId }, "User mapping missing Salesforce account ID"); + return []; + } - const fields = getSalesforceFieldMap(); const ordersSoql = ` SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount, CreatedDate, LastModifiedDate FROM Order - WHERE AccountId = '${userMapping.sfAccountId}' + WHERE AccountId = '${sfAccountId}' ORDER BY CreatedDate DESC LIMIT 50 `; try { - const ordersResult = (await this.sf.query( - ordersSoql - )) as SalesforceQueryResult; + const ordersResult = (await this.sf.query( + ordersSoql + )) as SalesforceQueryResult; const orders = ordersResult.records || []; if (orders.length === 0) { @@ -209,12 +297,20 @@ export class OrderOrchestrator { } // Get order items for all orders in one query - const orderIds = orders.map(o => `'${o.Id}'`).join(","); + const rawOrderIds = orders + .map(order => order.Id) + .filter((id): id is string => typeof id === "string"); + + if (rawOrderIds.length === 0) { + return []; + } + + const orderIdsClause = buildInClause(rawOrderIds, "orderIds"); const itemsSoql = ` SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice, ${getOrderItemProduct2Select()} FROM OrderItem - WHERE OrderId IN (${orderIds}) + WHERE OrderId IN (${orderIdsClause}) ORDER BY OrderId, CreatedDate ASC `; @@ -223,34 +319,12 @@ export class OrderOrchestrator { )) as SalesforceQueryResult; const allItems = itemsResult.records || []; - const itemsByOrder = allItems.reduce( - (acc, item) => { - const mapped = mapOrderItemForSummary(item, fields); - if (!acc[mapped.orderId]) acc[mapped.orderId] = []; - acc[mapped.orderId].push({ - name: mapped.product.name, - sku: mapped.product.sku, - itemClass: mapped.product.itemClass, - quantity: mapped.quantity, - unitPrice: mapped.unitPrice, - totalPrice: mapped.totalPrice, - billingCycle: mapped.billingCycle, - }); - return acc; - }, - {} as Record< - string, - Array<{ - name?: string; - sku?: string; - itemClass?: string; - quantity: number; - unitPrice?: number; - totalPrice?: number; - billingCycle?: string; - }> - > - ); + const itemsByOrder = allItems.reduce>((acc, record) => { + const details = mapOrderItemRecord(record); + if (!acc[details.orderId]) acc[details.orderId] = []; + acc[details.orderId].push(toOrderItemSummary(details)); + return acc; + }, {}); // Transform orders to domain types and return summary return orders.map(order => @@ -258,13 +332,13 @@ export class OrderOrchestrator { id: order.Id, orderNumber: order.OrderNumber, status: order.Status, - orderType: (order as any).OrderType || order.Type, + orderType: getOrderStringField(order, "orderType") ?? order.Type, effectiveDate: order.EffectiveDate, totalAmount: order.TotalAmount ?? 0, createdDate: order.CreatedDate, lastModifiedDate: order.LastModifiedDate, - whmcsOrderId: (order as any).WhmcsOrderId, - itemsSummary: itemsByOrder[order.Id] || [], + whmcsOrderId: getOrderStringField(order, "whmcsOrderId"), + itemsSummary: itemsByOrder[order.Id] ?? [], }) ); } catch (error) { diff --git a/apps/bff/src/modules/orders/services/order-pricebook.service.ts b/apps/bff/src/modules/orders/services/order-pricebook.service.ts new file mode 100644 index 00000000..8c455d45 --- /dev/null +++ b/apps/bff/src/modules/orders/services/order-pricebook.service.ts @@ -0,0 +1,128 @@ +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; +import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import { getStringField } from "@bff/modules/catalog/utils/salesforce-product.mapper"; +import type { + SalesforcePricebookEntryRecord, + SalesforceProduct2Record, + SalesforceQueryResult, +} from "@customer-portal/domain"; +import { + assertSalesforceId, + buildInClause, + sanitizeSoqlLiteral, +} from "@bff/integrations/salesforce/utils/soql.util"; + +interface PricebookProductMeta { + sku: string; + pricebookEntryId: string; + product2Id?: string; + unitPrice?: number; + itemClass?: string; + internetOfferingType?: string; + internetPlanTier?: string; + vpnRegion?: string; +} + +@Injectable() +export class OrderPricebookService { + private readonly chunkSize = 100; + + constructor( + private readonly sf: SalesforceConnection, + @Inject(Logger) private readonly logger: Logger + ) {} + + async findPortalPricebookId(): Promise { + const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal"; + const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${sanitizeSoqlLiteral(name)}%' LIMIT 1`; + + try { + const result = (await this.sf.query(soql)) as SalesforceQueryResult<{ Id?: string }>; + if (result.records?.length) { + const resolved = result.records[0]?.Id; + if (resolved) { + return assertSalesforceId(resolved, "pricebookId"); + } + } + + const std = (await this.sf.query( + "SELECT Id FROM Pricebook2 WHERE IsStandard = true AND IsActive = true LIMIT 1" + )) as SalesforceQueryResult<{ Id?: string }>; + + const pricebookId = std.records?.[0]?.Id; + if (!pricebookId) { + this.logger.error({ soql }, "No active pricebook found"); + throw new NotFoundException("No active pricebook found"); + } + + return assertSalesforceId(pricebookId, "pricebookId"); + } catch (error) { + this.logger.error({ error, soql }, "Failed to find pricebook"); + throw new NotFoundException("Failed to find pricebook"); + } + } + + async fetchProductMeta( + pricebookId: string, + skus: string[] + ): Promise> { + const safePricebookId = assertSalesforceId(pricebookId, "pricebookId"); + const uniqueSkus = Array.from( + new Set(skus.map(sku => sku?.trim()).filter((sku): sku is string => Boolean(sku))) + ); + + if (uniqueSkus.length === 0) { + return new Map(); + } + + const fields = getSalesforceFieldMap(); + const meta = new Map(); + + for (let i = 0; i < uniqueSkus.length; i += this.chunkSize) { + const slice = uniqueSkus.slice(i, i + this.chunkSize); + const whereIn = buildInClause(slice, "skus"); + const soql = + `SELECT Id, Product2Id, UnitPrice, ` + + `Product2.${fields.product.sku}, ` + + `Product2.${fields.product.itemClass}, ` + + `Product2.${fields.product.internetOfferingType}, ` + + `Product2.${fields.product.internetPlanTier}, ` + + `Product2.${fields.product.vpnRegion} ` + + `FROM PricebookEntry ` + + `WHERE Pricebook2Id='${safePricebookId}' AND IsActive=true AND Product2.${fields.product.sku} IN (${whereIn})`; + + try { + const res = (await this.sf.query(soql)) as SalesforceQueryResult< + SalesforcePricebookEntryRecord & { Product2?: SalesforceProduct2Record | null } + >; + + for (const record of res.records ?? []) { + const product = record.Product2 ?? undefined; + const sku = product ? getStringField(product, "sku") : undefined; + if (!sku) continue; + + const normalizedSku = sku.trim().toUpperCase(); + meta.set(normalizedSku, { + sku, + pricebookEntryId: assertSalesforceId(record.Id ?? undefined, "pricebookEntryId"), + product2Id: record.Product2Id ?? undefined, + unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined, + itemClass: product ? getStringField(product, "itemClass") : undefined, + internetOfferingType: product + ? getStringField(product, "internetOfferingType") + : undefined, + internetPlanTier: product ? getStringField(product, "internetPlanTier") : undefined, + vpnRegion: product ? getStringField(product, "vpnRegion") : undefined, + }); + } + } catch (error) { + this.logger.error({ error, slice }, "Failed to fetch pricebook entries by SKU"); + throw error; + } + } + + return meta; + } +} 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 dadd1bdc..f75c0342 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -2,10 +2,14 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service"; -import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain"; +import { + createOrderRequestSchema, + orderBusinessValidationSchema, + type CreateOrderRequest, + type OrderBusinessValidation, +} from "@customer-portal/domain"; +import { OrderPricebookService } from "./order-pricebook.service"; /** * Handles all order validation logic - both format and business rules @@ -16,7 +20,7 @@ export class OrderValidator { @Inject(Logger) private readonly logger: Logger, private readonly mappings: MappingsService, private readonly whmcs: WhmcsConnectionService, - private readonly sf: SalesforceConnection + private readonly pricebookService: OrderPricebookService ) {} /** @@ -35,30 +39,24 @@ export class OrderValidator { // Use direct Zod validation - simple and clean const validationResult = createOrderRequestSchema.safeParse(rawBody); - + if (!validationResult.success) { const errorMessages = validationResult.error.issues.map(issue => { - const path = issue.path.join('.'); + const path = issue.path.join("."); return path ? `${path}: ${issue.message}` : issue.message; }); - - this.logger.error( - { - errors: errorMessages, - rawBody: JSON.stringify(rawBody, null, 2) - }, - "Zod validation failed" - ); - + + this.logger.error({ errors: errorMessages.length }, "Zod validation failed"); + throw new BadRequestException({ - message: 'Order validation failed', + message: "Order validation failed", errors: errorMessages, statusCode: 400, }); } const validatedData = validationResult.data; - + // Return validated data directly (Zod ensures type safety) const validatedBody: CreateOrderRequest = validatedData; @@ -70,7 +68,7 @@ export class OrderValidator { }, "Zod request format validation passed" ); - + return validatedBody; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -82,9 +80,11 @@ export class OrderValidator { /** * Validate user mapping exists - simple business logic */ - async validateUserMapping(userId: string): Promise<{ userId: string; sfAccountId?: string; whmcsClientId: number }> { + async validateUserMapping( + userId: string + ): Promise<{ userId: string; sfAccountId?: string; whmcsClientId: number }> { const mapping = await this.mappings.findByUserId(userId); - + if (!mapping) { this.logger.warn({ userId }, "User mapping not found"); throw new BadRequestException("User account mapping is required before ordering"); @@ -113,10 +113,9 @@ export class OrderValidator { throw new BadRequestException("A payment method is required before ordering"); } } catch (e) { - this.logger.warn( - { err: getErrorMessage(e) }, - "Payment method check soft-failed; proceeding cautiously" - ); + const err = getErrorMessage(e); + this.logger.error({ err }, "Payment method verification failed"); + throw new BadRequestException("Unable to verify payment method. Please try again later."); } } @@ -138,7 +137,11 @@ export class OrderValidator { throw new BadRequestException("An Internet service already exists for this account"); } } catch (e) { - this.logger.warn({ err: getErrorMessage(e) }, "Internet duplicate check soft-failed"); + const err = getErrorMessage(e); + this.logger.error({ err }, "Internet duplicate check failed"); + throw new BadRequestException( + "Unable to verify existing Internet services. Please try again." + ); } } @@ -147,22 +150,15 @@ export class OrderValidator { */ async validateSKUs(skus: string[], pricebookId: string): Promise { const invalidSKUs: string[] = []; - const fields = getSalesforceFieldMap(); + const meta = await this.pricebookService.fetchProductMeta(pricebookId, skus); + const normalizedSkus = skus + .map(sku => sku?.trim()) + .filter((sku): sku is string => Boolean(sku)) + .map(sku => ({ raw: sku, normalized: sku.toUpperCase() })); - for (const sku of skus) { - if (!sku) continue; - - const safeSku = sku.replace(/'/g, "\\'"); - const soql = `SELECT Id FROM PricebookEntry WHERE Pricebook2Id='${pricebookId}' AND IsActive=true AND Product2.${fields.product.sku} = '${safeSku}' LIMIT 1`; - - try { - const res = (await this.sf.query(soql)) as { records?: Array<{ Id?: string }> }; - if (!res.records?.[0]?.Id) { - invalidSKUs.push(sku); - } - } catch (error) { - this.logger.error({ error, sku }, "Failed to validate SKU"); - invalidSKUs.push(sku); + for (const sku of normalizedSkus) { + if (!meta.has(sku.normalized)) { + invalidSKUs.push(sku.raw); } } @@ -229,37 +225,6 @@ export class OrderValidator { } } - /** - * Find Portal pricebook ID - */ - async findPortalPricebookId(): Promise { - const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal"; - const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${name}%' LIMIT 1`; - - try { - const result = (await this.sf.query(soql)) as { records?: Array<{ Id?: string }> }; - if (result.records?.length) { - return result.records[0].Id || ""; - } - - // fallback to Standard Price Book - const std = (await this.sf.query( - "SELECT Id FROM Pricebook2 WHERE IsStandard = true AND IsActive = true LIMIT 1" - )) as { records?: Array<{ Id?: string }> }; - - const pricebookId = std.records?.[0]?.Id; - if (!pricebookId) { - this.logger.error({ soql }, "No active pricebook found"); - throw new BadRequestException("No active pricebook found"); - } - - return pricebookId; - } catch (error) { - this.logger.error({ error, soql }, "Failed to find pricebook"); - throw new BadRequestException("Failed to find pricebook"); - } - } - /** * Complete order validation - performs ALL validation (format + business) */ @@ -267,39 +232,44 @@ export class OrderValidator { userId: string, rawBody: unknown ): Promise<{ - validatedBody: CreateOrderRequest; + validatedBody: OrderBusinessValidation; userMapping: { userId: string; sfAccountId?: string; whmcsClientId: number }; pricebookId: string; }> { - this.logger.log({ userId, rawBody }, "Starting complete order validation"); + this.logger.log({ userId }, "Starting complete order validation"); // 1. Format validation (replaces DTO validation) const validatedBody = this.validateRequestFormat(rawBody); + // 1b. Business validation (ensures userId-specific constraints) + const businessValidatedBody = orderBusinessValidationSchema.parse({ + ...validatedBody, + userId, + }); + // 2. User and payment validation const userMapping = await this.validateUserMapping(userId); await this.validatePaymentMethod(userId, userMapping.whmcsClientId); // 3. SKU validation - const pricebookId = await this.findPortalPricebookId(); - await this.validateSKUs(validatedBody.skus, pricebookId); - this.validateBusinessRules(validatedBody.orderType, validatedBody.skus); + const pricebookId = await this.pricebookService.findPortalPricebookId(); + await this.validateSKUs(businessValidatedBody.skus, pricebookId); + this.validateBusinessRules(businessValidatedBody.orderType, businessValidatedBody.skus); // 4. Order-specific business validation - if (validatedBody.orderType === "Internet") { + if (businessValidatedBody.orderType === "Internet") { await this.validateInternetDuplication(userId, userMapping.whmcsClientId); } this.logger.log( { userId, - orderType: validatedBody.orderType, - skuCount: validatedBody.skus.length, + orderType: businessValidatedBody.orderType, + skuCount: businessValidatedBody.skus.length, }, "Complete order validation passed" ); - return { validatedBody, userMapping, pricebookId }; + return { validatedBody: businessValidatedBody, userMapping, pricebookId }; } - } diff --git a/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts b/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts index d8f43bdd..879d8bc2 100644 --- a/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts +++ b/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts @@ -2,7 +2,7 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { WhmcsOrderItem } from "@bff/integrations/whmcs/services/whmcs-order.service"; -import type { OrderItemDto } from "../types/order-details.dto"; +import type { OrderDetailsResponse } from "@customer-portal/domain"; export interface OrderItemMappingResult { whmcsItems: WhmcsOrderItem[]; @@ -24,7 +24,7 @@ export class OrderWhmcsMapper { /** * Map Salesforce OrderItems to WHMCS format for provisioning */ - mapOrderItemsToWhmcs(orderItems: OrderItemDto[]): OrderItemMappingResult { + mapOrderItemsToWhmcs(orderItems: OrderDetailsResponse["items"]): OrderItemMappingResult { this.logger.log("Starting OrderItems mapping to WHMCS", { itemCount: orderItems.length, }); @@ -79,7 +79,10 @@ export class OrderWhmcsMapper { /** * Map a single Salesforce OrderItem to WHMCS format */ - private mapSingleOrderItem(item: OrderItemDto, index: number): WhmcsOrderItem { + private mapSingleOrderItem( + item: OrderDetailsResponse["items"][number], + index: number + ): WhmcsOrderItem { const product = item.product; // This is the transformed structure from OrderOrchestrator if (!product) { @@ -92,10 +95,14 @@ export class OrderWhmcsMapper { ); } + if (!product.billingCycle) { + throw new BadRequestException(`Product ${product.id} missing billing cycle`); + } + // Build WHMCS item - WHMCS products already have their billing cycles configured const whmcsItem: WhmcsOrderItem = { productId: product.whmcsProductId, - billingCycle: product.billingCycle.toLowerCase(), // Use the billing cycle from Salesforce OrderItem + billingCycle: product.billingCycle.toLowerCase(), quantity: item.quantity || 1, }; 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 64ce2597..47fcedd1 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -1,11 +1,10 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebititService } from "@bff/integrations/freebit/freebit.service"; -import { OrderDetailsDto } from "../types/order-details.dto"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import type { OrderDetailsResponse } from "@customer-portal/domain"; export interface SimFulfillmentRequest { - orderDetails: OrderDetailsDto; + orderDetails: OrderDetailsResponse; configurations: Record; } @@ -16,9 +15,6 @@ export class SimFulfillmentService { @Inject(Logger) private readonly logger: Logger ) {} - /** - * Handle SIM-specific fulfillment after WHMCs provisioning - */ async fulfillSimOrder(request: SimFulfillmentRequest): Promise { const { orderDetails, configurations } = request; @@ -27,16 +23,13 @@ export class SimFulfillmentService { orderType: orderDetails.orderType, }); - // Extract SIM-specific configurations const simType = configurations.simType as "eSIM" | "Physical SIM" | undefined; const eid = configurations.eid as string | undefined; const activationType = configurations.activationType as "Immediate" | "Scheduled" | undefined; const scheduledAt = configurations.scheduledAt as string | undefined; const phoneNumber = configurations.mnpPhone as string | undefined; const mnp = this.extractMnpConfig(configurations); - const addons = this.extractAddonConfig(configurations); - // Find the main SIM plan from order items const simPlanItem = orderDetails.items.find( item => item.product.itemClass === "Plan" || item.product.sku?.toLowerCase().includes("sim") ); @@ -50,7 +43,6 @@ export class SimFulfillmentService { throw new Error("SIM plan SKU not found"); } - // Validate required fields if (simType === "eSIM" && (!eid || eid.length < 15)) { throw new Error("EID is required for eSIM and must be valid"); } @@ -59,7 +51,6 @@ export class SimFulfillmentService { throw new Error("Phone number is required for SIM activation"); } - // Perform Freebit activation await this.activateSim({ account: phoneNumber, eid, @@ -68,7 +59,6 @@ export class SimFulfillmentService { activationType: activationType || "Immediate", scheduledAt, mnp, - addons, }); this.logger.log("SIM fulfillment completed successfully", { @@ -78,9 +68,6 @@ export class SimFulfillmentService { }); } - /** - * Activate SIM via Freebit API - */ private async activateSim(params: { account: string; eid?: string; @@ -99,15 +86,10 @@ export class SimFulfillmentService { gender?: string; birthday?: string; }; - addons?: { - voiceMail?: boolean; - callWaiting?: boolean; - }; }): Promise { - const { account, eid, planSku, simType, activationType, scheduledAt, mnp, addons } = params; + const { account, eid, planSku, simType, activationType, scheduledAt, mnp } = params; try { - // Activate eSIM if applicable if (simType === "eSIM") { await this.freebit.activateEsimAccountNew({ account, @@ -144,20 +126,6 @@ export class SimFulfillmentService { account, }); } - - // Apply add-ons (voice options) if selected - if (addons && (addons.voiceMail || addons.callWaiting)) { - await this.freebit.updateSimFeatures(account, { - voiceMailEnabled: !!addons.voiceMail, - callWaitingEnabled: !!addons.callWaiting, - }); - - this.logger.log("SIM add-ons applied", { - account, - voiceMail: addons.voiceMail, - callWaiting: addons.callWaiting, - }); - } } catch (error) { this.logger.error("SIM activation failed", { account, @@ -168,12 +136,9 @@ export class SimFulfillmentService { } } - /** - * Extract MNP configuration from order configurations - */ private extractMnpConfig(configurations: Record) { const isMnp = configurations.isMnp; - if (!isMnp || isMnp !== "true") { + if (isMnp !== "true") { return undefined; } @@ -189,14 +154,4 @@ export class SimFulfillmentService { birthday: configurations.portingDateOfBirth as string | undefined, }; } - - /** - * Extract addon configuration from order configurations - */ - private extractAddonConfig(configurations: Record) { - // Check if voice addons are present in the configurations - // This would need to be determined based on the order items or configurations - // For now, return undefined - this can be enhanced based on actual addon detection logic - return undefined; - } } diff --git a/apps/bff/src/modules/subscriptions/sim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management.service.ts index 8153ee0d..bccbe2e3 100644 --- a/apps/bff/src/modules/subscriptions/sim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management.service.ts @@ -4,7 +4,11 @@ import { FreebititService } from "@bff/integrations/freebit/freebit.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { SubscriptionsService } from "./subscriptions.service"; -import { SimDetails, SimUsage, SimTopUpHistory } from "@bff/integrations/freebit/interfaces/freebit.types"; +import { + SimDetails, + SimUsage, + SimTopUpHistory, +} from "@bff/integrations/freebit/interfaces/freebit.types"; import { SimUsageStoreService } from "./sim-usage-store.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { EmailService } from "@bff/infra/email/email.service"; diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index bf7df595..4665233b 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -23,9 +23,9 @@ import { import { SubscriptionsService } from "./subscriptions.service"; import { SimManagementService } from "./sim-management.service"; -import { - Subscription, - SubscriptionList, +import { + Subscription, + SubscriptionList, InvoiceList, simTopupRequestSchema, simChangePlanRequestSchema, @@ -34,7 +34,7 @@ import { type SimTopupRequest, type SimChangePlanRequest, type SimCancelRequest, - type SimFeaturesRequest + type SimFeaturesRequest, } from "@customer-portal/domain"; import { ZodValidationPipe } from "@bff/core/validation"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index ececd389..fc51e218 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -1,10 +1,20 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; -import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain"; +import { + Subscription, + SubscriptionList, + InvoiceList, + invoiceListSchema, +} from "@customer-portal/domain"; import type { Invoice, InvoiceItem } from "@customer-portal/domain"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { Logger } from "nestjs-pino"; +import { z } from "zod"; +import { + subscriptionSchema, + type SubscriptionSchema, +} from "@customer-portal/domain/validation/shared/entities"; export interface GetSubscriptionsOptions { status?: string; @@ -28,28 +38,36 @@ export class SubscriptionsService { const { status } = options; try { - // Get WHMCS client ID from user mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { throw new NotFoundException("WHMCS client mapping not found"); } - // Fetch subscriptions from WHMCS const subscriptionList = await this.whmcsService.getSubscriptions( mapping.whmcsClientId, userId, { status } ); - this.logger.log( - `Retrieved ${subscriptionList.subscriptions.length} subscriptions for user ${userId}`, - { - status, - totalCount: subscriptionList.totalCount, - } - ); + const parsed = z + .object({ + subscriptions: z.array(subscriptionSchema), + totalCount: z.number(), + }) + .safeParse(subscriptionList); - return subscriptionList; + if (!parsed.success) { + throw new Error(parsed.error.message); + } + + const filtered = status + ? parsed.data.subscriptions.filter(sub => sub.status.toLowerCase() === status.toLowerCase()) + : parsed.data.subscriptions; + + return { + subscriptions: filtered, + totalCount: filtered.length, + } satisfies SubscriptionList; } catch (error) { this.logger.error(`Failed to get subscriptions for user ${userId}`, { error: getErrorMessage(error), @@ -355,12 +373,14 @@ export class SubscriptionsService { // Get all invoices for the user WITH ITEMS (needed for subscription linking) // TODO: Consider implementing server-side filtering in WHMCS service to improve performance - const allInvoices = await this.whmcsService.getInvoicesWithItems( + const invoicesResponse = await this.whmcsService.getInvoicesWithItems( mapping.whmcsClientId, userId, { page: 1, limit: 1000 } // Get more to filter locally ); + const allInvoices = invoicesResponse; + // Filter invoices that have items related to this subscription // Note: subscriptionId is the same as serviceId in our current WHMCS mapping this.logger.debug( @@ -487,4 +507,29 @@ export class SubscriptionsService { }; } } + + private async checkUserHasExistingSim(userId: string): Promise { + try { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId) { + return false; + } + + const products = await this.whmcsService.getClientsProducts({ + clientid: mapping.whmcsClientId, + }); + const services = products?.products?.product ?? []; + + return services.some( + service => + typeof service.groupname === "string" && + service.groupname.toLowerCase().includes("sim") && + typeof service.status === "string" && + service.status.toLowerCase() === "active" + ); + } catch (error) { + this.logger.warn(`Failed to check existing SIM for user ${userId}`, error); + return false; + } + } } diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index 31a4368b..52ff879f 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -11,11 +11,11 @@ import { import { UsersService } from "./users.service"; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger"; import { ZodValidationPipe } from "@bff/core/validation"; -import { +import { updateProfileRequestSchema, updateAddressRequestSchema, type UpdateProfileRequest, - type UpdateAddressRequest + type UpdateAddressRequest, } from "@customer-portal/domain"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index c4694e85..7b55dff8 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -24,7 +24,19 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; interface SalesforceAccount extends SalesforceAccountRecord {} // Use a subset of PrismaUser for updates -type UserUpdateData = Partial>; +type UserUpdateData = Partial< + Pick< + PrismaUser, + | "firstName" + | "lastName" + | "company" + | "phone" + | "passwordHash" + | "failedLoginAttempts" + | "lastLoginAt" + | "lockedUntil" + > +>; @Injectable() export class UsersService { @@ -192,7 +204,7 @@ export class UsersService { phone: phone || user.phone, email: email || user.email, }; - + return this.toDomainUser(enhancedUser); } @@ -347,7 +359,7 @@ export class UsersService { ) .sort( (a: Invoice, b: Invoice) => - new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime() + new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime() ); if (upcomingInvoices.length > 0) { diff --git a/apps/bff/tsconfig.json b/apps/bff/tsconfig.json index 2b45e9ca..da051e35 100644 --- a/apps/bff/tsconfig.json +++ b/apps/bff/tsconfig.json @@ -31,6 +31,6 @@ "module": "CommonJS" } }, - "include": ["src/**/*"], + "include": ["src/**/*", "scripts/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index aa7cedc7..20db9180 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -5,7 +5,7 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; -import { createClient } from "@/lib/api"; +import { apiClient } from "@/lib/api"; import { getErrorInfo, handleAuthError } from "@/lib/utils/error-handling"; import logger from "@customer-portal/logging"; import type { @@ -17,10 +17,14 @@ import type { } from "@customer-portal/domain"; import { authResponseSchema } from "@customer-portal/domain/validation"; -// Create API client instance -const apiClient = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", -}); +const withAuthHeaders = (accessToken?: string) => + accessToken + ? { + headers: { + Authorization: `Bearer ${accessToken}`, + } as Record, + } + : {}; interface AuthState { // State @@ -67,12 +71,8 @@ export const useAuthStore = create()( login: async (credentials: LoginRequest) => { set({ loading: true, error: null }); try { - // Configure API client with no auth for login - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - }); - - const response = await client.POST('/api/auth/login', { body: credentials }); + // Use shared API client with consistent configuration + const response = await apiClient.POST('/auth/login', { body: credentials }); const parsed = authResponseSchema.safeParse(response.data); if (!parsed.success) { throw new Error(parsed.error.issues?.[0]?.message ?? 'Login failed'); @@ -100,11 +100,7 @@ export const useAuthStore = create()( signup: async (data: SignupRequest) => { set({ loading: true, error: null }); try { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - }); - - const response = await client.POST('/api/auth/signup', { body: data }); + const response = await apiClient.POST('/auth/signup', { body: data }); const parsed = authResponseSchema.safeParse(response.data); if (!parsed.success) { throw new Error(parsed.error.issues?.[0]?.message ?? 'Signup failed'); @@ -133,12 +129,9 @@ export const useAuthStore = create()( try { if (tokens?.accessToken) { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - getAuthHeader: () => `Bearer ${tokens.accessToken}`, + await apiClient.POST('/auth/logout', { + ...withAuthHeaders(tokens.accessToken), }); - - await client.POST('/api/auth/logout'); } } catch (error) { // Ignore logout errors - clear local state anyway @@ -156,14 +149,10 @@ export const useAuthStore = create()( requestPasswordReset: async (email: string) => { set({ loading: true, error: null }); try { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - }); - - const response = await client.POST('/api/auth/request-password-reset', { + const response = await apiClient.POST('/auth/request-password-reset', { body: { email } }); - + if (!response.data) { throw new Error('Password reset request failed'); } @@ -181,11 +170,7 @@ export const useAuthStore = create()( resetPassword: async (token: string, password: string) => { set({ loading: true, error: null }); try { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - }); - - const response = await client.POST('/api/auth/reset-password', { + const response = await apiClient.POST('/auth/reset-password', { body: { token, password } }); const parsed = authResponseSchema.safeParse(response.data); @@ -217,15 +202,11 @@ export const useAuthStore = create()( set({ loading: true, error: null }); try { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - getAuthHeader: () => `Bearer ${tokens.accessToken}`, - }); - - const response = await client.POST('/api/auth/change-password', { + const response = await apiClient.POST('/auth/change-password', { + ...withAuthHeaders(tokens.accessToken), body: { currentPassword, newPassword } }); - + if (!response.data) { throw new Error('Password change failed'); } @@ -243,14 +224,10 @@ export const useAuthStore = create()( checkPasswordNeeded: async (email: string) => { set({ loading: true, error: null }); try { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - }); - - const response = await client.POST('/api/auth/check-password-needed', { + const response = await apiClient.POST('/auth/check-password-needed', { body: { email } }); - + if (!response.data) { throw new Error('Check failed'); } @@ -269,11 +246,7 @@ export const useAuthStore = create()( linkWhmcs: async ({ email, password }: LinkWhmcsRequestData) => { set({ loading: true, error: null }); try { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - }); - - const response = await client.POST('/api/auth/link-whmcs', { + const response = await apiClient.POST('/auth/link-whmcs', { body: { email, password } }); @@ -296,11 +269,7 @@ export const useAuthStore = create()( setPassword: async (email: string, password: string) => { set({ loading: true, error: null }); try { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - }); - - const response = await client.POST('/api/auth/set-password', { + const response = await apiClient.POST('/auth/set-password', { body: { email, password } }); const parsed = authResponseSchema.safeParse(response.data); @@ -331,13 +300,10 @@ export const useAuthStore = create()( if (!tokens?.accessToken) return; try { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - getAuthHeader: () => `Bearer ${tokens.accessToken}`, + const response = await apiClient.GET('/me', { + ...withAuthHeaders(tokens.accessToken), }); - - const response = await client.GET('/api/me'); - + if (!response.data) { // Token might be expired, try to refresh await get().refreshTokens(); @@ -361,11 +327,7 @@ export const useAuthStore = create()( } try { - const client = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - }); - - const response = await client.POST('/api/auth/refresh', { + const response = await apiClient.POST('/auth/refresh', { body: { refreshToken: tokens.refreshToken, deviceId: localStorage.getItem('deviceId') || undefined, diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx index 7add0bfd..84857285 100644 --- a/apps/portal/src/features/orders/components/OrderCard.tsx +++ b/apps/portal/src/features/orders/components/OrderCard.tsx @@ -4,6 +4,12 @@ import { ReactNode } from "react"; import { AnimatedCard } from "@/components/molecules"; import { StatusPill } from "@/components/atoms/status-pill"; import { WifiIcon, DevicePhoneMobileIcon, LockClosedIcon, CubeIcon } from "@heroicons/react/24/outline"; +import { + calculateOrderTotals, + deriveOrderStatusDescriptor, + getServiceCategory, + summarizePrimaryItem, +} from "@/features/orders/utils/order-presenters"; export interface OrderSummaryLike { id: string | number; @@ -33,86 +39,45 @@ export interface OrderCardProps { className?: string; } -interface StatusInfo { - label: string; - description: string; - variant: "success" | "info" | "neutral"; -} +const STATUS_PILL_VARIANT = { + success: "success", + info: "info", + warning: "warning", + neutral: "neutral", +} as const; -function getStatusInfo(status: string, activationStatus?: string): StatusInfo { - if (activationStatus === "Activated") { - return { - label: "Active", - description: "Your service is active and ready to use", - variant: "success", - }; - } - if (status === "Draft" || status === "Pending Review") { - return { - label: "Under Review", - description: "We're reviewing your order", - variant: "info", - }; - } - if (activationStatus === "Activating") { - return { - label: "Setting Up", - description: "We're preparing your service", - variant: "info", - }; - } - return { - label: status || "Processing", - description: "Order is being processed", - variant: "neutral", - }; -} +const SERVICE_LABELS = { + internet: "Internet", + sim: "Mobile", + vpn: "VPN", + default: "Service", +} as const; -function getServiceTypeDisplay(orderType?: string): { icon: ReactNode; label: string } { - switch (orderType) { - case "Internet": - return { icon: , label: "Internet" }; - case "SIM": - return { icon: , label: "Mobile" }; - case "VPN": - return { icon: , label: "VPN" }; +const renderServiceIcon = (orderType?: string): ReactNode => { + const category = getServiceCategory(orderType); + switch (category) { + case "internet": + return ; + case "sim": + return ; + case "vpn": + return ; default: - return { icon: , label: "Service" }; + return ; } -} - -function getServiceSummary(order: OrderSummaryLike): string { - if (order.itemsSummary && order.itemsSummary.length > 0) { - const mainItem = order.itemsSummary[0]; - const additionalCount = order.itemsSummary.length - 1; - let summary = mainItem.name || "Service"; - if (additionalCount > 0) summary += ` +${additionalCount} more`; - return summary; - } - return order.itemSummary || "Service package"; -} - -function calculateOrderTotals(order: OrderSummaryLike): { monthlyTotal: number; oneTimeTotal: number } { - let monthlyTotal = 0; - let oneTimeTotal = 0; - if (order.itemsSummary && order.itemsSummary.length > 0) { - order.itemsSummary.forEach(item => { - const totalPrice = item.totalPrice || 0; - const billingCycle = (item.billingCycle || "").toLowerCase(); - if (billingCycle === "monthly") monthlyTotal += totalPrice; - else oneTimeTotal += totalPrice; - }); - } else { - monthlyTotal = order.totalAmount || 0; - } - return { monthlyTotal, oneTimeTotal }; -} +}; export function OrderCard({ order, onClick, footer, className }: OrderCardProps) { - const statusInfo = getStatusInfo(order.status, order.activationStatus); - const serviceType = getServiceTypeDisplay(order.orderType); - const serviceSummary = getServiceSummary(order); - const totals = calculateOrderTotals(order); + const statusDescriptor = deriveOrderStatusDescriptor({ + status: order.status, + activationStatus: order.activationStatus, + }); + const statusVariant = STATUS_PILL_VARIANT[statusDescriptor.tone]; + const serviceIcon = renderServiceIcon(order.orderType); + const serviceCategory = getServiceCategory(order.orderType); + const serviceLabel = SERVICE_LABELS[serviceCategory]; + const serviceSummary = summarizePrimaryItem(order.itemsSummary, order.itemSummary || "Service package"); + const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount); return (
- {serviceType.icon} - {serviceType.label} + {serviceIcon} + {serviceLabel} Order #{order.orderNumber || String(order.id).slice(-8)}
- +
@@ -142,7 +107,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) })}

{serviceSummary}

-

{statusInfo.description}

+

{statusDescriptor.description}

{(totals.monthlyTotal > 0 || totals.oneTimeTotal > 0) && (
@@ -180,5 +145,3 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) } export default OrderCard; - - diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index de674f4c..4cd25b4a 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -1,28 +1,31 @@ import { createClient } from "@/lib/api"; +import type { CreateOrderRequest } from "@customer-portal/domain"; -export interface CreateOrderRequest { - orderType: "Internet" | "SIM" | "VPN" | "Other"; - skus: string[]; - configurations?: Record; -} +const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000"; + +const getAuthHeader = (): string | undefined => { + if (typeof window === "undefined") return undefined; + + const authStore = window.localStorage.getItem("auth-store"); + if (!authStore) return undefined; + + try { + const parsed = JSON.parse(authStore); + const token = parsed?.state?.tokens?.accessToken; + return token ? `Bearer ${token}` : undefined; + } catch { + return undefined; + } +}; + +const createAuthedClient = () => + createClient({ + baseUrl: API_BASE, + getAuthHeader, + }); async function createOrder(payload: CreateOrderRequest): Promise { - const apiClient = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - getAuthHeader: () => { - const authStore = localStorage.getItem('auth-store'); - if (authStore) { - try { - const parsed = JSON.parse(authStore); - const token = parsed?.state?.tokens?.accessToken; - return token ? `Bearer ${token}` : undefined; - } catch { - return undefined; - } - } - return undefined; - } - }); + const apiClient = createAuthedClient(); const response = await apiClient.POST("/api/orders", { body: payload }); if (!response.data) { throw new Error("Order creation failed"); @@ -31,43 +34,13 @@ async function createOrder(payload: CreateOrderReques } async function getMyOrders(): Promise { - const apiClient = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - getAuthHeader: () => { - const authStore = localStorage.getItem('auth-store'); - if (authStore) { - try { - const parsed = JSON.parse(authStore); - const token = parsed?.state?.tokens?.accessToken; - return token ? `Bearer ${token}` : undefined; - } catch { - return undefined; - } - } - return undefined; - } - }); + const apiClient = createAuthedClient(); const response = await apiClient.GET("/api/orders/user"); return (response.data ?? []) as T; } async function getOrderById(orderId: string): Promise { - const apiClient = createClient({ - baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000", - getAuthHeader: () => { - const authStore = localStorage.getItem('auth-store'); - if (authStore) { - try { - const parsed = JSON.parse(authStore); - const token = parsed?.state?.tokens?.accessToken; - return token ? `Bearer ${token}` : undefined; - } catch { - return undefined; - } - } - return undefined; - } - }); + const apiClient = createAuthedClient(); const response = await apiClient.GET("/api/orders/{sfOrderId}", { params: { path: { sfOrderId: orderId } }, }); diff --git a/apps/portal/src/features/orders/utils/order-presenters.ts b/apps/portal/src/features/orders/utils/order-presenters.ts new file mode 100644 index 00000000..290b8cd5 --- /dev/null +++ b/apps/portal/src/features/orders/utils/order-presenters.ts @@ -0,0 +1,168 @@ +export type OrderServiceCategory = "internet" | "sim" | "vpn" | "default"; + +export type StatusTone = "success" | "info" | "warning" | "neutral"; + +export type OrderStatusState = + | "active" + | "review" + | "scheduled" + | "activating" + | "processing"; + +export interface OrderStatusDescriptor { + label: string; + state: OrderStatusState; + tone: StatusTone; + description: string; + nextAction?: string; + timeline?: string; + scheduledDate?: string; +} + +interface StatusInput { + status: string; + activationStatus?: string; + activationType?: string; + scheduledAt?: string; +} + +export function deriveOrderStatusDescriptor({ + status, + activationStatus, + scheduledAt, +}: StatusInput): OrderStatusDescriptor { + if (activationStatus === "Activated") { + return { + label: "Service Active", + state: "active", + tone: "success", + description: "Your service is active and ready to use", + timeline: "Service activated successfully", + }; + } + + if (status === "Draft" || status === "Pending Review") { + return { + label: "Under Review", + state: "review", + tone: "info", + description: "Our team is reviewing your order details", + nextAction: "We will contact you within 1 business day with next steps", + timeline: "Review typically takes 1 business day", + }; + } + + if (activationStatus === "Scheduled") { + const scheduledDate = formatScheduledDate(scheduledAt); + return { + label: "Installation Scheduled", + state: "scheduled", + tone: "warning", + description: "Your installation has been scheduled", + nextAction: scheduledDate + ? `Installation scheduled for ${scheduledDate}` + : "Installation will be scheduled shortly", + timeline: "Please be available during the scheduled time", + scheduledDate, + }; + } + + if (activationStatus === "Activating") { + return { + label: "Setting Up Service", + state: "activating", + tone: "info", + description: "We're configuring your service", + nextAction: "Installation team will contact you to schedule", + timeline: "Setup typically takes 3-5 business days", + }; + } + + return { + label: status || "Processing", + state: "processing", + tone: "neutral", + description: "Your order is being processed", + timeline: "We will update you as progress is made", + }; +} + +export function getServiceCategory(orderType?: string): OrderServiceCategory { + switch (orderType) { + case "Internet": + return "internet"; + case "SIM": + return "sim"; + case "VPN": + return "vpn"; + default: + return "default"; + } +} + +export function summarizePrimaryItem( + items: Array<{ name?: string; quantity?: number }> | undefined, + fallback: string +): string { + if (!items || items.length === 0) { + return fallback; + } + + const [primary, ...rest] = items; + let summary = primary?.name || fallback; + const additionalCount = rest.filter(Boolean).length; + if (additionalCount > 0) { + summary += ` +${additionalCount} more`; + } + return summary; +} + +export interface OrderTotals { + monthlyTotal: number; + oneTimeTotal: number; +} + +export function calculateOrderTotals( + items: Array<{ totalPrice?: number; billingCycle?: string }> | undefined, + fallbackTotal?: number +): OrderTotals { + let monthlyTotal = 0; + let oneTimeTotal = 0; + + if (items && items.length > 0) { + for (const item of items) { + const total = item.totalPrice ?? 0; + const billingCycle = normalizeBillingCycle(item.billingCycle); + if (billingCycle === "monthly") { + monthlyTotal += total; + } else if (billingCycle === "onetime") { + oneTimeTotal += total; + } else { + monthlyTotal += total; + } + } + } else if (typeof fallbackTotal === "number") { + monthlyTotal = fallbackTotal; + } + + return { monthlyTotal, oneTimeTotal }; +} + +export function normalizeBillingCycle(value?: string): "monthly" | "onetime" | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "monthly") return "monthly"; + if (normalized === "onetime" || normalized === "one-time") return "onetime"; + return null; +} + +export function formatScheduledDate(scheduledAt?: string): string | undefined { + if (!scheduledAt) return undefined; + const date = new Date(scheduledAt); + if (Number.isNaN(date.getTime())) return undefined; + return date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); +} diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 93887d53..ebb7a7b7 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -14,6 +14,7 @@ import { import { SubCard } from "@/components/molecules/SubCard"; import { StatusPill } from "@/components/atoms/status-pill"; import { ordersService } from "@/features/orders/services/orders.service"; +import { calculateOrderTotals, deriveOrderStatusDescriptor, getServiceCategory } from "@/features/orders/utils/order-presenters"; interface OrderItem { id: string; @@ -30,15 +31,6 @@ interface OrderItem { }; } -interface StatusInfo { - label: string; - color: string; - bgColor: string; - description: string; - nextAction?: string; - timeline?: string; -} - interface OrderSummary { id: string; orderNumber?: string; @@ -56,90 +48,26 @@ interface OrderSummary { items?: OrderItem[]; } -const getDetailedStatusInfo = ( - status: string, - activationStatus?: string, - activationType?: string, - scheduledAt?: string -): StatusInfo => { - if (activationStatus === "Activated") { - return { - label: "Service Active", - color: "text-green-800", - bgColor: "bg-green-50 border-green-200", - description: "Your service is active and ready to use", - timeline: "Service activated successfully", - }; - } - if (status === "Draft" || status === "Pending Review") { - return { - label: "Under Review", - color: "text-blue-800", - bgColor: "bg-blue-50 border-blue-200", - description: "Our team is reviewing your order details", - nextAction: "We will contact you within 1 business day with next steps", - timeline: "Review typically takes 1 business day", - }; - } - if (activationStatus === "Scheduled") { - const scheduledDate = scheduledAt - ? new Date(scheduledAt).toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - }) - : "soon"; - return { - label: "Installation Scheduled", - color: "text-orange-800", - bgColor: "bg-orange-50 border-orange-200", - description: "Your installation has been scheduled", - nextAction: `Installation scheduled for ${scheduledDate}`, - timeline: "Please be available during the scheduled time", - }; - } - if (activationStatus === "Activating") { - return { - label: "Setting Up Service", - color: "text-purple-800", - bgColor: "bg-purple-50 border-purple-200", - description: "We're configuring your service", - nextAction: "Installation team will contact you to schedule", - timeline: "Setup typically takes 3-5 business days", - }; - } - return { - label: status || "Processing", - color: "text-gray-800", - bgColor: "bg-gray-50 border-gray-200", - description: "Your order is being processed", - timeline: "We will update you as progress is made", - }; +const STATUS_PILL_VARIANT: Record<"success" | "info" | "warning" | "neutral", "success" | "info" | "warning" | "neutral"> = { + success: "success", + info: "info", + warning: "warning", + neutral: "neutral", }; -const getOrderTypeIcon = (orderType?: string) => { - switch (orderType) { - case "Internet": - return ; - case "SIM": - return ; - case "VPN": - return ; +const renderServiceIcon = (category: ReturnType, className: string) => { + switch (category) { + case "internet": + return ; + case "sim": + return ; + case "vpn": + return ; default: - return ; + return ; } }; -const calculateDetailedTotals = (items: OrderItem[]) => { - let monthlyTotal = 0; - let oneTimeTotal = 0; - items.forEach(item => { - if (item.product.billingCycle === "Monthly") monthlyTotal += item.totalPrice || 0; - else oneTimeTotal += item.totalPrice || 0; - }); - return { monthlyTotal, oneTimeTotal }; -}; - export function OrderDetailContainer() { const params = useParams<{ id: string }>(); const searchParams = useSearchParams(); @@ -147,6 +75,28 @@ export function OrderDetailContainer() { const [error, setError] = useState(null); const isNewOrder = searchParams.get("status") === "success"; + const statusDescriptor = data + ? deriveOrderStatusDescriptor({ + status: data.status, + activationStatus: data.activationStatus, + scheduledAt: data.scheduledAt, + }) + : null; + + const statusPillVariant = statusDescriptor + ? STATUS_PILL_VARIANT[statusDescriptor.tone] + : STATUS_PILL_VARIANT.neutral; + + const serviceCategory = getServiceCategory(data?.orderType); + const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6"); + const totals = calculateOrderTotals( + data?.items?.map(item => ({ + totalPrice: item.totalPrice, + billingCycle: item.product?.billingCycle, + })), + data?.totalAmount + ); + useEffect(() => { let mounted = true; const fetchStatus = async () => { @@ -205,58 +155,37 @@ export function OrderDetailContainer() {
)} - {data && (() => { - const statusInfo = getDetailedStatusInfo( - data.status, - data.activationStatus, - data.activationType, - data.scheduledAt - ); - const statusVariant = statusInfo.label.includes("Active") - ? "success" - : statusInfo.label.includes("Review") || - statusInfo.label.includes("Setting Up") || - statusInfo.label.includes("Scheduled") - ? "info" - : "neutral"; - return ( - Status} - > -
-
{statusInfo.description}
- -
- {statusInfo.nextAction && ( -
-
- - - - Next Steps -
-

{statusInfo.nextAction}

+ {data && statusDescriptor && ( + Status}> +
+
{statusDescriptor.description}
+ +
+ {statusDescriptor.nextAction && ( +
+
+ + + + Next Steps
- )} - {statusInfo.timeline && ( -
{statusInfo.timeline}
- )} - - ); - })()} +

{statusDescriptor.nextAction}

+
+ )} + {statusDescriptor.timeline && ( +
{statusDescriptor.timeline}
+ )} +
+ )} {data && ( - {getOrderTypeIcon(data.orderType)} + {serviceIcon}

Order Items

} @@ -283,25 +212,20 @@ export function OrderDetailContainer() { ))} - {(() => { - const totals = calculateDetailedTotals(data.items || []); - return ( -
-
-
- ¥{totals.monthlyTotal.toLocaleString()} {" "} - /mo -
- {totals.oneTimeTotal > 0 && ( -
- ¥{totals.oneTimeTotal.toLocaleString()} {" "} - one-time -
- )} -
+
+
+
+ ¥{totals.monthlyTotal.toLocaleString()} {" "} + /mo
- ); - })()} + {totals.oneTimeTotal > 0 && ( +
+ ¥{totals.oneTimeTotal.toLocaleString()} {" "} + one-time +
+ )} +
+
)}
@@ -311,5 +235,3 @@ export function OrderDetailContainer() { } export default OrderDetailContainer; - - diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index 9751c6c7..593c4535 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -3,15 +3,7 @@ import { useEffect, useState, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { PageLayout } from "@/components/templates/PageLayout"; -import { - ClipboardDocumentListIcon, - CheckCircleIcon, - WifiIcon, - DevicePhoneMobileIcon, - LockClosedIcon, - CubeIcon, -} from "@heroicons/react/24/outline"; -import { StatusPill } from "@/components/atoms/status-pill"; +import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { AnimatedCard } from "@/components/molecules"; import { AlertBanner } from "@/components/molecules/AlertBanner"; import { ordersService } from "@/features/orders/services/orders.service"; @@ -40,14 +32,6 @@ interface OrderSummary { }>; } -interface StatusInfo { - label: string; - color: string; - bgColor: string; - description: string; - nextAction?: string; -} - function OrdersSuccessBanner() { const searchParams = useSearchParams(); const showSuccess = searchParams.get("status") === "success"; @@ -90,81 +74,6 @@ export function OrdersListContainer() { void fetchOrders(); }, []); - const getStatusInfo = (status: string, activationStatus?: string): StatusInfo => { - if (activationStatus === "Activated") { - return { - label: "Active", - color: "text-green-800", - bgColor: "bg-green-100", - description: "Your service is active and ready to use", - }; - } - if (status === "Draft" || status === "Pending Review") { - return { - label: "Under Review", - color: "text-blue-800", - bgColor: "bg-blue-100", - description: "We're reviewing your order", - nextAction: "We'll contact you within 1 business day", - }; - } - if (activationStatus === "Activating") { - return { - label: "Setting Up", - color: "text-orange-800", - bgColor: "bg-orange-100", - description: "We're preparing your service", - nextAction: "Installation will be scheduled soon", - }; - } - return { - label: status || "Processing", - color: "text-gray-800", - bgColor: "bg-gray-100", - description: "Order is being processed", - }; - }; - - const getServiceTypeDisplay = (orderType?: string) => { - switch (orderType) { - case "Internet": - return { icon: , label: "Internet Service" }; - case "SIM": - return { icon: , label: "Mobile Service" }; - case "VPN": - return { icon: , label: "VPN Service" }; - default: - return { icon: , label: "Service" }; - } - }; - - const getServiceSummary = (order: OrderSummary) => { - if (order.itemsSummary && order.itemsSummary.length > 0) { - const mainItem = order.itemsSummary[0]; - const additionalCount = order.itemsSummary.length - 1; - let summary = mainItem.name || "Service"; - if (additionalCount > 0) summary += ` +${additionalCount} more`; - return summary; - } - return order.itemSummary || "Service package"; - }; - - const calculateOrderTotals = (order: OrderSummary) => { - let monthlyTotal = 0; - let oneTimeTotal = 0; - if (order.itemsSummary && order.itemsSummary.length > 0) { - order.itemsSummary.forEach(item => { - const totalPrice = item.totalPrice || 0; - const billingCycle = item.billingCycle?.toLowerCase() || ""; - if (billingCycle === "monthly") monthlyTotal += totalPrice; - else oneTimeTotal += totalPrice; - }); - } else { - monthlyTotal = order.totalAmount || 0; - } - return { monthlyTotal, oneTimeTotal } as const; - }; - return ( } @@ -208,5 +117,3 @@ export function OrdersListContainer() { } export default OrdersListContainer; - - diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index 5b997d33..a7789eb0 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -5,6 +5,7 @@ export type { AuthHeaderResolver, CreateClientOptions, } from "./runtime/client"; +export { ApiError, isApiError } from "./runtime/client"; // Re-export response helpers export * from "./response-helpers"; diff --git a/apps/portal/src/lib/utils/error-handling.ts b/apps/portal/src/lib/utils/error-handling.ts index 8ace263a..55cc5461 100644 --- a/apps/portal/src/lib/utils/error-handling.ts +++ b/apps/portal/src/lib/utils/error-handling.ts @@ -3,15 +3,17 @@ * Provides consistent error handling and user-friendly messages */ -export interface ApiError { +import { ApiError as ClientApiError } from "@/lib/api"; + +export interface ApiErrorPayload { success: false; error: { code: string; message: string; details?: Record; }; - timestamp: string; - path: string; + timestamp?: string; + path?: string; } export interface ApiErrorInfo { @@ -25,14 +27,11 @@ export interface ApiErrorInfo { * Extract error information from various error types */ export function getErrorInfo(error: unknown): ApiErrorInfo { - // Handle API errors with structured format - if (isApiError(error)) { - return { - code: error.error.code, - message: error.error.message, - shouldLogout: shouldLogoutForError(error.error.code), - shouldRetry: shouldRetryForError(error.error.code), - }; + if (error instanceof ClientApiError) { + const info = parseClientApiError(error); + if (info) { + return info; + } } // Handle fetch/network errors @@ -68,16 +67,14 @@ export function getErrorInfo(error: unknown): ApiErrorInfo { }; } -export function isApiError(error: unknown): error is ApiError { +export function isApiErrorPayload(error: unknown): error is ApiErrorPayload { return ( typeof error === 'object' && error !== null && 'success' in error && - error.success === false && + (error as { success?: unknown }).success === false && 'error' in error && - typeof (error as any).error === 'object' && - 'code' in (error as any).error && - 'message' in (error as any).error + typeof (error as { error?: unknown }).error === 'object' ); } @@ -149,3 +146,129 @@ export function createErrorLog(error: unknown, context: string): { timestamp: new Date().toISOString(), }; } + +function parseClientApiError(error: ClientApiError): ApiErrorInfo | null { + const status = error.response?.status; + const parsedBody = parseRawErrorBody(error.body); + + const payloadInfo = parsedBody + ? deriveInfoFromPayload(parsedBody, status) + : null; + + if (payloadInfo) { + return payloadInfo; + } + + return { + code: status ? httpStatusCodeToLabel(status) : 'API_ERROR', + message: error.message, + shouldLogout: status === 401, + shouldRetry: typeof status === 'number' ? status >= 500 : true, + }; +} + +function parseRawErrorBody(body: unknown): unknown { + if (!body) { + return null; + } + + if (typeof body === 'string') { + try { + return JSON.parse(body); + } catch { + return body; + } + } + + return body; +} + +function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo | null { + if (isApiErrorPayload(payload)) { + const code = payload.error.code; + return { + code, + message: payload.error.message, + shouldLogout: shouldLogoutForError(code) || status === 401, + shouldRetry: shouldRetryForError(code), + }; + } + + if (isGlobalErrorPayload(payload)) { + const code = payload.code || payload.error || httpStatusCodeToLabel(status); + const message = payload.message || 'Request failed. Please try again.'; + const derivedStatus = payload.statusCode ?? status; + + return { + code, + message, + shouldLogout: shouldLogoutForError(code) || derivedStatus === 401, + shouldRetry: + typeof derivedStatus === 'number' + ? derivedStatus >= 500 + : shouldRetryForError(code), + }; + } + + if ( + typeof payload === 'object' && + payload !== null && + 'message' in payload && + typeof (payload as { message?: unknown }).message === 'string' + ) { + const code = typeof (payload as { code?: unknown }).code === 'string' + ? (payload as { code: string }).code + : httpStatusCodeToLabel(status); + + return { + code, + message: (payload as { message: string }).message, + shouldLogout: shouldLogoutForError(code) || status === 401, + shouldRetry: + typeof status === 'number' + ? status >= 500 + : shouldRetryForError(code), + }; + } + + return null; +} + +function isGlobalErrorPayload(payload: unknown): payload is { + success: false; + code?: string; + message?: string; + error?: string; + statusCode?: number; +} { + return ( + typeof payload === 'object' && + payload !== null && + 'success' in payload && + (payload as { success?: unknown }).success === false && + ('code' in payload || 'message' in payload || 'error' in payload) + ); +} + +function httpStatusCodeToLabel(status?: number): string { + if (!status) { + return 'API_ERROR'; + } + + switch (status) { + case 400: + return 'BAD_REQUEST'; + case 401: + return 'UNAUTHORIZED'; + case 403: + return 'FORBIDDEN'; + case 404: + return 'NOT_FOUND'; + case 409: + return 'CONFLICT'; + case 422: + return 'UNPROCESSABLE_ENTITY'; + default: + return status >= 500 ? 'SERVER_ERROR' : `HTTP_${status}`; + } +} diff --git a/packages/domain/src/contracts/salesforce.ts b/packages/domain/src/contracts/salesforce.ts index 2bd09f65..8a56d82c 100644 --- a/packages/domain/src/contracts/salesforce.ts +++ b/packages/domain/src/contracts/salesforce.ts @@ -53,6 +53,35 @@ export interface SalesforcePricebookEntryRecord extends SalesforceSObjectBase { Product2?: SalesforceProduct2Record | null; } +export interface SalesforceProduct2WithPricebookEntries + extends SalesforceProduct2Record { + PricebookEntries?: { + records?: SalesforcePricebookEntryRecord[]; + }; +} + +export interface SalesforceProductFieldMap { + sku: string; + portalCategory: string; + portalCatalog: string; + portalAccessible: string; + itemClass: string; + billingCycle: string; + whmcsProductId: string; + whmcsProductName: string; + internetPlanTier: string; + internetOfferingType: string; + displayOrder: string; + bundledAddon: string; + isBundledAddon: string; + simDataSize: string; + simPlanType: string; + simHasFamilyDiscount: string; + vpnRegion: string; + featureList?: string; + featureSet?: string; +} + export interface SalesforceAccountRecord extends SalesforceSObjectBase { Name?: string; SF_Account_No__c?: string | null; @@ -114,4 +143,3 @@ export interface SalesforceContactRecord extends SalesforceSObjectBase { Phone?: string | null; } - diff --git a/packages/domain/src/entities/invoice.ts b/packages/domain/src/entities/invoice.ts index c579df1b..272cea42 100644 --- a/packages/domain/src/entities/invoice.ts +++ b/packages/domain/src/entities/invoice.ts @@ -1,42 +1,9 @@ // Invoice types from WHMCS -import type { InvoiceStatus } from "../enums/status"; -import type { WhmcsEntity } from "../common"; - -export interface Invoice extends WhmcsEntity { - number: string; - status: InvoiceStatus; - currency: string; // e.g. 'USD', 'JPY' - currencySymbol?: string; // e.g. '¥', '¥' - total: number; // decimal as number - subtotal: number; - tax: number; - issuedAt?: string; // ISO - dueDate?: string; // ISO - paidDate?: string; // ISO - pdfUrl?: string; // via /sso-link - paymentUrl?: string; // via /sso-link - description?: string; - items?: InvoiceItem[]; -} - -export interface InvoiceItem { - id: number; - description: string; - amount: number; - quantity?: number; - type: string; - serviceId?: number; // Links to the related service/product via WHMCS relid field -} - -export interface InvoiceList { - invoices: Invoice[]; - pagination: { - page: number; - totalPages: number; - totalItems: number; - nextCursor?: string; - }; -} +export type { + InvoiceSchema as Invoice, + InvoiceItemSchema as InvoiceItem, + InvoiceListSchema as InvoiceList, +} from "../validation"; export interface InvoiceSsoLink { url: string; diff --git a/packages/domain/src/entities/user.ts b/packages/domain/src/entities/user.ts index dfa4895e..dee971dc 100644 --- a/packages/domain/src/entities/user.ts +++ b/packages/domain/src/entities/user.ts @@ -1,5 +1,6 @@ // User and authentication types import type { BaseEntity, Address, IsoDateTimeString } from "../common"; +import type { InvoiceList } from "./invoice"; export interface User extends BaseEntity { email: string; @@ -21,12 +22,7 @@ export interface UserSummary { totalSpent: number; currency: string; }; - nextInvoice?: { - id: number; - dueDate: string; - amount: number; - currency: string; - }; + nextInvoice?: InvoiceList["invoices"][number]; recentActivity: Activity[]; } diff --git a/packages/domain/src/validation/api/responses.ts b/packages/domain/src/validation/api/responses.ts index 3c864b5e..96570a72 100644 --- a/packages/domain/src/validation/api/responses.ts +++ b/packages/domain/src/validation/api/responses.ts @@ -26,6 +26,3 @@ export type AuthTokensSchema = AuthResponse["tokens"]; export { orderDetailsSchema, orderSummarySchema }; export type OrderDetailsResponse = z.infer; export type OrderSummaryResponse = z.infer; -export { orderDetailsSchema, orderSummarySchema }; -export type OrderDetailsResponse = z.infer; -export type OrderSummaryResponse = z.infer; diff --git a/packages/domain/src/validation/forms/auth.ts b/packages/domain/src/validation/forms/auth.ts index 423fc520..30d3b01e 100644 --- a/packages/domain/src/validation/forms/auth.ts +++ b/packages/domain/src/validation/forms/auth.ts @@ -3,6 +3,8 @@ * Frontend form schemas that extend API request schemas with UI-specific fields */ +import { z } from "zod"; + import { loginRequestSchema, signupRequestSchema, diff --git a/packages/domain/src/validation/index.ts b/packages/domain/src/validation/index.ts index 4faa0c5e..c2b6f4f8 100644 --- a/packages/domain/src/validation/index.ts +++ b/packages/domain/src/validation/index.ts @@ -78,6 +78,8 @@ export { authResponseSchema, type AuthResponse, type AuthTokensSchema, + type OrderDetailsResponse, + type OrderSummaryResponse, } from "./api/responses"; export { diff --git a/packages/domain/src/validation/shared/entities.ts b/packages/domain/src/validation/shared/entities.ts index d909af95..43d6c3ec 100644 --- a/packages/domain/src/validation/shared/entities.ts +++ b/packages/domain/src/validation/shared/entities.ts @@ -176,6 +176,16 @@ export const invoiceSchema = whmcsEntitySchema.extend({ items: z.array(invoiceItemSchema).optional(), }); +export const invoiceListSchema = z.object({ + invoices: z.array(invoiceSchema), + pagination: z.object({ + page: z.number().int().nonnegative(), + totalPages: z.number().int().nonnegative(), + totalItems: z.number().int().nonnegative(), + nextCursor: z.string().optional(), + }), +}); + // ===================================================== // SUBSCRIPTION ENTITIES (WHMCS) // ===================================================== @@ -279,6 +289,7 @@ export type WhmcsOrderItemSchema = z.infer; export type WhmcsOrderSchema = z.infer; export type InvoiceItemSchema = z.infer; export type InvoiceSchema = z.infer; +export type InvoiceListSchema = z.infer; export type SubscriptionSchema = z.infer; export type PaymentMethodSchema = z.infer; export type PaymentSchema = z.infer;