From 10c8461661058b037d57dd29220f141b76adc03e Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 26 Dec 2025 13:04:15 +0900 Subject: [PATCH] Implement Zod DTOs for Request Validation and Enhance ESLint Rules - Introduced Zod DTOs for request validation across multiple controllers, replacing inline validation with structured classes for improved maintainability and clarity. - Updated ESLint configuration to enforce a rule against importing Zod directly in BFF controllers, promoting the use of shared domain schemas for request validation. - Removed the SecureErrorMapperService to streamline the security module, as its functionality was deemed unnecessary. - Enhanced various controllers to utilize the new DTOs, ensuring consistent validation and response handling across the application. --- apps/bff/src/app.module.ts | 13 +- apps/bff/src/core/security/security.module.ts | 5 +- .../services/secure-error-mapper.service.ts | 537 ------------------ .../auth/presentation/http/auth.controller.ts | 74 ++- .../modules/invoices/invoices.controller.ts | 83 +-- .../src/modules/invoices/invoices.module.ts | 2 +- .../notifications/notifications.controller.ts | 51 +- .../orders/controllers/checkout.controller.ts | 98 ++-- .../src/modules/orders/orders.controller.ts | 47 +- .../services/order-orchestrator.service.ts | 5 +- .../services/order-validator.service.ts | 49 +- .../modules/realtime/realtime.controller.ts | 2 + .../internet-eligibility.controller.ts | 17 +- .../subscriptions/sim-orders.controller.ts | 14 +- .../subscriptions/subscriptions.controller.ts | 234 ++++---- .../src/modules/support/support.controller.ts | 37 +- .../bff/src/modules/users/users.controller.ts | 34 +- apps/portal/src/lib/api/index.ts | 6 + apps/portal/src/lib/api/runtime/client.ts | 7 +- apps/portal/src/lib/utils/error-handling.ts | 36 +- docs/README.md | 11 +- docs/STRUCTURE.md | 6 +- docs/development/bff/validation.md | 61 ++ docs/development/domain/packages.md | 20 +- eslint.config.mjs | 17 + packages/domain/billing/index.ts | 2 + packages/domain/billing/schema.ts | 19 + packages/domain/notifications/index.ts | 4 + packages/domain/notifications/schema.ts | 17 + packages/domain/orders/index.ts | 1 + packages/domain/orders/schema.ts | 51 ++ packages/domain/payments/contract.ts | 15 +- packages/domain/payments/index.ts | 2 +- packages/domain/payments/schema.ts | 7 + packages/domain/services/schema.ts | 11 + packages/domain/sim/index.ts | 8 + packages/domain/sim/schema.ts | 60 +- 37 files changed, 682 insertions(+), 981 deletions(-) delete mode 100644 apps/bff/src/core/security/services/secure-error-mapper.service.ts create mode 100644 docs/development/bff/validation.md diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index b8724f28..268fa4db 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -1,9 +1,10 @@ import { Module } from "@nestjs/common"; -import { APP_PIPE } from "@nestjs/core"; +import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; import { RouterModule } from "@nestjs/core"; import { ConfigModule } from "@nestjs/config"; import { ScheduleModule } from "@nestjs/schedule"; -import { ZodValidationPipe } from "nestjs-zod"; +import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod"; +import { TransformInterceptor } from "@bff/core/http/transform.interceptor.js"; // Configuration import { appConfig } from "@bff/core/config/app.config.js"; @@ -105,6 +106,14 @@ import { HealthModule } from "@bff/modules/health/health.module.js"; provide: APP_PIPE, useClass: ZodValidationPipe, }, + { + provide: APP_INTERCEPTOR, + useClass: TransformInterceptor, + }, + { + provide: APP_INTERCEPTOR, + useClass: ZodSerializerInterceptor, + }, ], }) export class AppModule {} diff --git a/apps/bff/src/core/security/security.module.ts b/apps/bff/src/core/security/security.module.ts index 06360bbe..fe27fa49 100644 --- a/apps/bff/src/core/security/security.module.ts +++ b/apps/bff/src/core/security/security.module.ts @@ -1,7 +1,6 @@ import { Module } from "@nestjs/common"; import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; -import { SecureErrorMapperService } from "./services/secure-error-mapper.service.js"; import { CsrfService } from "./services/csrf.service.js"; import { CsrfMiddleware } from "./middleware/csrf.middleware.js"; import { CsrfController } from "./controllers/csrf.controller.js"; @@ -10,8 +9,8 @@ import { AdminGuard } from "./guards/admin.guard.js"; @Module({ imports: [ConfigModule], controllers: [CsrfController], - providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware, AdminGuard], - exports: [SecureErrorMapperService, CsrfService, AdminGuard], + providers: [CsrfService, CsrfMiddleware, AdminGuard], + exports: [CsrfService, AdminGuard], }) export class SecurityModule implements NestModule { configure(consumer: MiddlewareConsumer) { diff --git a/apps/bff/src/core/security/services/secure-error-mapper.service.ts b/apps/bff/src/core/security/services/secure-error-mapper.service.ts deleted file mode 100644 index 67a7918c..00000000 --- a/apps/bff/src/core/security/services/secure-error-mapper.service.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Logger } from "nestjs-pino"; - -export interface ErrorContext { - userId?: string; - requestId?: string; - userAgent?: string; - ip?: string; - url?: string; - method?: string; -} - -export interface SecureErrorMapping { - code: string; - publicMessage: string; - logLevel: "error" | "warn" | "info" | "debug"; - shouldAlert?: boolean; // Whether to send alerts to monitoring -} - -export interface ErrorClassification { - category: "authentication" | "authorization" | "validation" | "business" | "system" | "external"; - severity: "low" | "medium" | "high" | "critical"; - mapping: SecureErrorMapping; -} - -/** - * Service for secure error message mapping to prevent information leakage - * Maps internal errors to safe public messages while preserving security - */ -@Injectable() -export class SecureErrorMapperService { - private readonly isDevelopment: boolean; - private readonly errorMappings: Map; - private readonly patternMappings: Array<{ pattern: RegExp; mapping: SecureErrorMapping }>; - - constructor( - private readonly configService: ConfigService, - @Inject(Logger) private readonly logger: Logger - ) { - this.isDevelopment = this.configService.get("NODE_ENV") !== "production"; - this.errorMappings = this.initializeErrorMappings(); - this.patternMappings = this.initializePatternMappings(); - } - - /** - * Map an error to a secure public message - */ - mapError(error: unknown, context?: ErrorContext): ErrorClassification { - const errorMessage = this.extractErrorMessage(error); - const errorCode = this.extractErrorCode(error); - - // Try exact code mapping first - if (errorCode && this.errorMappings.has(errorCode)) { - const mapping = this.errorMappings.get(errorCode)!; - return this.createClassification(errorMessage, mapping, context); - } - - // Try pattern matching - for (const { pattern, mapping } of this.patternMappings) { - if (pattern.test(errorMessage)) { - return this.createClassification(errorMessage, mapping, context); - } - } - - // Default fallback - const defaultMapping = this.getDefaultMapping(errorMessage); - return this.createClassification(errorMessage, defaultMapping, context); - } - - /** - * Get a safe error message for client consumption - */ - getPublicMessage(error: unknown, context?: ErrorContext): string { - const classification = this.mapError(error, context); - - // In development, show more details - if (this.isDevelopment) { - const originalMessage = this.extractErrorMessage(error); - return `${classification.mapping.publicMessage} (Dev: ${this.sanitizeForDevelopment(originalMessage)})`; - } - - return classification.mapping.publicMessage; - } - - /** - * Log error with appropriate security level - */ - logSecureError( - error: unknown, - context?: ErrorContext, - additionalData?: Record - ): void { - const classification = this.mapError(error, context); - const originalMessage = this.extractErrorMessage(error); - - const logData = { - errorCode: classification.mapping.code, - category: classification.category, - severity: classification.severity, - publicMessage: classification.mapping.publicMessage, - originalMessage: this.sanitizeForLogging(originalMessage), - context, - ...additionalData, - }; - - // Log based on severity and log level - switch (classification.mapping.logLevel) { - case "error": - this.logger.error(`Security Error: ${classification.mapping.code}`, logData); - break; - case "warn": - this.logger.warn(`Security Warning: ${classification.mapping.code}`, logData); - break; - case "info": - this.logger.log(`Security Info: ${classification.mapping.code}`, logData); - break; - case "debug": - this.logger.debug(`Security Debug: ${classification.mapping.code}`, logData); - break; - } - - // Send alerts for critical errors - if (classification.mapping.shouldAlert && classification.severity === "critical") { - this.sendSecurityAlert(classification, context, logData); - } - } - - private initializeErrorMappings(): Map { - return new Map([ - // Authentication Errors - [ - "INVALID_CREDENTIALS", - { - code: "AUTH_001", - publicMessage: "Invalid email or password", - logLevel: "warn", - }, - ], - [ - "ACCOUNT_LOCKED", - { - code: "AUTH_002", - publicMessage: "Account temporarily locked. Please try again later", - logLevel: "warn", - }, - ], - [ - "TOKEN_EXPIRED", - { - code: "AUTH_003", - publicMessage: "Session expired. Please log in again", - logLevel: "info", - }, - ], - [ - "TOKEN_INVALID", - { - code: "AUTH_004", - publicMessage: "Invalid session. Please log in again", - logLevel: "warn", - }, - ], - [ - "SESSION_EXPIRED", - { - code: "SESSION_EXPIRED", - publicMessage: "Your session has expired. Please log in again", - logLevel: "info", - }, - ], - - // Authorization Errors - [ - "INSUFFICIENT_PERMISSIONS", - { - code: "AUTHZ_001", - publicMessage: "You do not have permission to perform this action", - logLevel: "warn", - }, - ], - [ - "RESOURCE_NOT_FOUND", - { - code: "AUTHZ_002", - publicMessage: "The requested resource was not found", - logLevel: "info", - }, - ], - - // Validation Errors - [ - "VALIDATION_FAILED", - { - code: "VAL_001", - publicMessage: "The provided data is invalid", - logLevel: "info", - }, - ], - [ - "REQUIRED_FIELD_MISSING", - { - code: "VAL_002", - publicMessage: "Required information is missing", - logLevel: "info", - }, - ], - [ - "ENDPOINT_NOT_FOUND", - { - code: "VAL_003", - publicMessage: "The requested resource was not found", - logLevel: "info", - }, - ], - - // Business Logic Errors - [ - "ORDER_ALREADY_PROCESSED", - { - code: "BIZ_001", - publicMessage: "This order has already been processed", - logLevel: "info", - }, - ], - [ - "INSUFFICIENT_BALANCE", - { - code: "BIZ_002", - publicMessage: "Insufficient account balance", - logLevel: "info", - }, - ], - [ - "SERVICE_UNAVAILABLE", - { - code: "BIZ_003", - publicMessage: "Service is temporarily unavailable", - logLevel: "warn", - }, - ], - - // System Errors (High Security) - [ - "DATABASE_ERROR", - { - code: "SYS_001", - publicMessage: "A system error occurred. Please try again later", - logLevel: "error", - shouldAlert: true, - }, - ], - [ - "EXTERNAL_SERVICE_ERROR", - { - code: "SYS_002", - publicMessage: "External service temporarily unavailable", - logLevel: "error", - }, - ], - [ - "CONFIGURATION_ERROR", - { - code: "SYS_003", - publicMessage: "System configuration error", - logLevel: "error", - shouldAlert: true, - }, - ], - - // Rate Limiting - [ - "RATE_LIMIT_EXCEEDED", - { - code: "RATE_001", - publicMessage: "Too many requests. Please try again later", - logLevel: "warn", - }, - ], - - // Generic Fallbacks - [ - "UNKNOWN_ERROR", - { - code: "GEN_001", - publicMessage: "An unexpected error occurred", - logLevel: "error", - shouldAlert: true, - }, - ], - ]); - } - - private initializePatternMappings(): Array<{ pattern: RegExp; mapping: SecureErrorMapping }> { - return [ - // Database-related patterns - { - pattern: /database|connection|sql|prisma|postgres/i, - mapping: { - code: "SYS_001", - publicMessage: "A system error occurred. Please try again later", - logLevel: "error", - shouldAlert: true, - }, - }, - - // Authentication patterns - { - pattern: /token expired or expiring soon/i, - mapping: { - code: "SESSION_EXPIRED", - publicMessage: "Your session has expired. Please log in again", - logLevel: "info", - }, - }, - { - pattern: /password|credential|token|secret|key|auth/i, - mapping: { - code: "AUTH_001", - publicMessage: "Authentication failed", - logLevel: "warn", - }, - }, - - // File system patterns - { - pattern: /file|path|directory|permission denied|enoent|eacces/i, - mapping: { - code: "SYS_002", - publicMessage: "System resource error", - logLevel: "error", - shouldAlert: true, - }, - }, - - // Network/External service patterns - { - pattern: /network|timeout|connection refused|econnrefused|whmcs|salesforce/i, - mapping: { - code: "SYS_002", - publicMessage: "External service temporarily unavailable", - logLevel: "error", - }, - }, - - // Stack trace patterns - { - pattern: /\s+at\s+|\.js:\d+|\.ts:\d+|stack trace/i, - mapping: { - code: "SYS_001", - publicMessage: "A system error occurred. Please try again later", - logLevel: "error", - shouldAlert: true, - }, - }, - - // Memory/Resource patterns - { - pattern: /memory|heap|out of memory|resource|limit exceeded/i, - mapping: { - code: "SYS_003", - publicMessage: "System resources temporarily unavailable", - logLevel: "error", - shouldAlert: true, - }, - }, - - // HTTP/Routing patterns - { - pattern: /^Cannot\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+/i, - mapping: { - code: "VAL_003", - publicMessage: "The requested resource was not found", - logLevel: "info", - }, - }, - - // Validation patterns - { - pattern: /invalid|required|missing|validation|format/i, - mapping: { - code: "VAL_001", - publicMessage: "The provided data is invalid", - logLevel: "info", - }, - }, - ]; - } - - private createClassification( - originalMessage: string, - mapping: SecureErrorMapping, - _context?: ErrorContext - ): ErrorClassification { - // Determine category and severity based on error code - const category = this.determineCategory(mapping.code); - const severity = this.determineSeverity(mapping.code, originalMessage); - - return { - category, - severity, - mapping, - }; - } - - private determineCategory(code: string): ErrorClassification["category"] { - if (code === "SESSION_EXPIRED") return "authentication"; - if (code.startsWith("AUTH_")) return "authentication"; - if (code.startsWith("AUTHZ_")) return "authorization"; - if (code.startsWith("VAL_")) return "validation"; - if (code.startsWith("BIZ_")) return "business"; - if (code.startsWith("SYS_")) return "system"; - return "system"; - } - - private determineSeverity(code: string, message: string): ErrorClassification["severity"] { - // Critical system errors - if (code === "SYS_001" || code === "SYS_003") return "critical"; - - // High severity for authentication issues - if (code === "SESSION_EXPIRED") return "medium"; - if (code.startsWith("AUTH_") && message.toLowerCase().includes("breach")) return "high"; - - // Medium for external service issues - if (code === "SYS_002") return "medium"; - - // Low for validation and business logic - if (code.startsWith("VAL_") || code.startsWith("BIZ_")) return "low"; - - return "medium"; - } - - private getDefaultMapping(message: string): SecureErrorMapping { - // Analyze message for sensitivity - if (this.containsSensitiveInfo(message)) { - return { - code: "SYS_001", - publicMessage: "A system error occurred. Please try again later", - logLevel: "error", - shouldAlert: true, - }; - } - - return { - code: "GEN_001", - publicMessage: "An unexpected error occurred", - logLevel: "error", - }; - } - - private containsSensitiveInfo(message: string): boolean { - const sensitivePatterns = [ - /password|secret|key|token|credential/i, - /database|sql|connection/i, - /file|path|directory/i, - /\s+at\s+.*\.js:\d+/i, // Stack traces - /[a-zA-Z]:[\\/]/, // Windows paths - /\/[a-zA-Z0-9._\-/]+\.(js|ts|py|php)/i, // Unix paths - /\b(?:\d{1,3}\.){3}\d{1,3}\b/, // IP addresses - /[A-Za-z0-9]{32,}/, // Long tokens/hashes - ]; - - return sensitivePatterns.some(pattern => pattern.test(message)); - } - - private extractErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === "string") { - return error; - } - if (typeof error === "object" && error !== null) { - const obj = error as Record; - if (typeof obj.message === "string") { - return obj.message; - } - } - return "Unknown error"; - } - - private extractErrorCode(error: unknown): string | null { - if (typeof error === "object" && error !== null) { - const obj = error as Record; - if (typeof obj.code === "string") { - return obj.code; - } - } - return null; - } - - private sanitizeForLogging(message: string): string { - return ( - message - // Remove file paths - .replace(/[/][a-zA-Z0-9._\-/]+\.(js|ts|py|php)/g, "[file]") - // Remove stack traces - .replace(/\s+at\s+.*/g, "") - // Remove absolute paths - .replace(/[a-zA-Z]:[\\/][^:]+/g, "[path]") - // Remove IP addresses - .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]") - // Remove URLs with credentials - .replace(/https?:\/\/[^:]+:[^@]+@[^\s]+/g, "[url]") - // Remove potential secrets - .replace(/\b[A-Za-z0-9]{32,}\b/g, "[token]") - .trim() - ); - } - - private sanitizeForDevelopment(message: string): string { - // In development, show more but still remove the most sensitive parts - return message - .replace(/password[=:]\s*[^\s]+/gi, "password=[HIDDEN]") - .replace(/secret[=:]\s*[^\s]+/gi, "secret=[HIDDEN]") - .replace(/token[=:]\s*[^\s]+/gi, "token=[HIDDEN]") - .replace(/key[=:]\s*[^\s]+/gi, "key=[HIDDEN]"); - } - - private sendSecurityAlert( - classification: ErrorClassification, - context?: ErrorContext, - logData?: Record - ): void { - // In a real implementation, this would send alerts to monitoring systems - // like Slack, PagerDuty, or custom alerting systems - this.logger.error("SECURITY ALERT TRIGGERED", { - alertType: "CRITICAL_ERROR", - errorCode: classification.mapping.code, - category: classification.category, - severity: classification.severity, - context, - timestamp: new Date().toISOString(), - ...logData, - }); - } -} diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index c765a78e..841223d6 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -7,7 +7,6 @@ import { Get, Req, HttpCode, - UsePipes, Res, } from "@nestjs/common"; import type { Request, Response } from "express"; @@ -20,7 +19,7 @@ import { } from "./guards/failed-login-throttle.guard.js"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js"; import { Public } from "../../decorators/public.decorator.js"; -import { ZodValidationPipe } from "nestjs-zod"; +import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js"; @@ -43,17 +42,6 @@ import { refreshTokenRequestSchema, checkPasswordNeededResponseSchema, linkWhmcsResponseSchema, - type SignupRequest, - type PasswordResetRequest, - type ResetPasswordRequest, - type SetPasswordRequest, - type LinkWhmcsRequest, - type ChangePasswordRequest, - type ValidateSignupRequest, - type AccountStatusRequest, - type SsoLinkRequest, - type CheckPasswordNeededRequest, - type RefreshTokenRequest, } from "@customer-portal/domain/auth"; type CookieValue = string | undefined; @@ -61,6 +49,20 @@ type RequestWithCookies = Omit & { cookies?: Record; }; +class ValidateSignupRequestDto extends createZodDto(validateSignupRequestSchema) {} +class SignupRequestDto extends createZodDto(signupRequestSchema) {} +class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {} +class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {} +class LinkWhmcsRequestDto extends createZodDto(linkWhmcsRequestSchema) {} +class SetPasswordRequestDto extends createZodDto(setPasswordRequestSchema) {} +class CheckPasswordNeededRequestDto extends createZodDto(checkPasswordNeededRequestSchema) {} +class PasswordResetRequestDto extends createZodDto(passwordResetRequestSchema) {} +class ResetPasswordRequestDto extends createZodDto(passwordResetSchema) {} +class ChangePasswordRequestDto extends createZodDto(changePasswordRequestSchema) {} +class SsoLinkRequestDto extends createZodDto(ssoLinkRequestSchema) {} +class LinkWhmcsResponseDto extends createZodDto(linkWhmcsResponseSchema) {} +class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {} + const calculateCookieMaxAge = (isoTimestamp: string): number => { const expiresAt = Date.parse(isoTimestamp); if (Number.isNaN(expiresAt)) { @@ -128,8 +130,7 @@ export class AuthController { @Post("validate-signup") @UseGuards(RateLimitGuard, SalesforceReadThrottleGuard) @RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP - @UsePipes(new ZodValidationPipe(validateSignupRequestSchema)) - async validateSignup(@Body() validateData: ValidateSignupRequest, @Req() req: Request) { + async validateSignup(@Body() validateData: ValidateSignupRequestDto, @Req() req: Request) { return this.authFacade.validateSignup(validateData, req); } @@ -143,16 +144,14 @@ export class AuthController { @Post("signup-preflight") @UseGuards(RateLimitGuard, SalesforceReadThrottleGuard) @RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP - @UsePipes(new ZodValidationPipe(signupRequestSchema)) @HttpCode(200) - async signupPreflight(@Body() signupData: SignupRequest) { + async signupPreflight(@Body() signupData: SignupRequestDto) { return this.authFacade.signupPreflight(signupData); } @Public() @Post("account-status") - @UsePipes(new ZodValidationPipe(accountStatusRequestSchema)) - async accountStatus(@Body() body: AccountStatusRequest) { + async accountStatus(@Body() body: AccountStatusRequestDto) { return this.authFacade.getAccountStatus(body.email); } @@ -160,9 +159,8 @@ export class AuthController { @Post("signup") @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @RateLimit({ limit: 5, ttl: 900 }) // 5 signups per 15 minutes per IP (reasonable for account creation) - @UsePipes(new ZodValidationPipe(signupRequestSchema)) async signup( - @Body() signupData: SignupRequest, + @Body() signupData: SignupRequestDto, @Req() req: Request, @Res({ passthrough: true }) res: Response ) { @@ -212,9 +210,8 @@ export class AuthController { @Post("refresh") @UseGuards(RateLimitGuard) @RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP - @UsePipes(new ZodValidationPipe(refreshTokenRequestSchema)) async refreshToken( - @Body() body: RefreshTokenRequest, + @Body() body: RefreshTokenRequestDto, @Req() req: RequestWithCookies, @Res({ passthrough: true }) res: Response ) { @@ -233,19 +230,18 @@ export class AuthController { @Post("migrate") @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) - @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) - async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { + @ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto }) + async migrateAccount(@Body() linkData: LinkWhmcsRequestDto, @Req() _req: Request) { const result = await this.authFacade.linkWhmcsUser(linkData); - return linkWhmcsResponseSchema.parse(result); + return result; } @Public() @Post("set-password") @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard) - @UsePipes(new ZodValidationPipe(setPasswordRequestSchema)) async setPassword( - @Body() setPasswordData: SetPasswordRequest, + @Body() setPasswordData: SetPasswordRequestDto, @Req() _req: Request, @Res({ passthrough: true }) res: Response ) { @@ -256,19 +252,22 @@ export class AuthController { @Public() @Post("check-password-needed") - @UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema)) @HttpCode(200) - async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequest) { + @ZodResponse({ + status: 200, + description: "Check if password is needed", + type: CheckPasswordNeededResponseDto, + }) + async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) { const response = await this.authFacade.checkPasswordNeeded(data.email); - return checkPasswordNeededResponseSchema.parse(response); + return response; } @Public() @Post("request-password-reset") @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) - @UsePipes(new ZodValidationPipe(passwordResetRequestSchema)) - async requestPasswordReset(@Body() body: PasswordResetRequest, @Req() req: Request) { + async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) { await this.authFacade.requestPasswordReset(body.email, req); return { message: "If an account exists, a reset email has been sent" }; } @@ -278,9 +277,8 @@ export class AuthController { @HttpCode(200) @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) - @UsePipes(new ZodValidationPipe(passwordResetSchema)) async resetPassword( - @Body() body: ResetPasswordRequest, + @Body() body: ResetPasswordRequestDto, @Res({ passthrough: true }) res: Response ) { await this.authFacade.resetPassword(body.token, body.password); @@ -293,10 +291,9 @@ export class AuthController { @Post("change-password") @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes - @UsePipes(new ZodValidationPipe(changePasswordRequestSchema)) async changePassword( @Req() req: Request & { user: { id: string } }, - @Body() body: ChangePasswordRequest, + @Body() body: ChangePasswordRequestDto, @Res({ passthrough: true }) res: Response ) { const result = await this.authFacade.changePassword(req.user.id, body, req); @@ -310,10 +307,9 @@ export class AuthController { } @Post("sso-link") - @UsePipes(new ZodValidationPipe(ssoLinkRequestSchema)) async createSsoLink( @Req() req: Request & { user: { id: string } }, - @Body() body: SsoLinkRequest + @Body() body: SsoLinkRequestDto ) { const destination = body?.destination; return this.authFacade.createSsoLink(req.user.id, destination); diff --git a/apps/bff/src/modules/invoices/invoices.controller.ts b/apps/bff/src/modules/invoices/invoices.controller.ts index c3e4bbc2..5b191dc2 100644 --- a/apps/bff/src/modules/invoices/invoices.controller.ts +++ b/apps/bff/src/modules/invoices/invoices.controller.ts @@ -8,32 +8,48 @@ import { ParseIntPipe, HttpCode, HttpStatus, - BadRequestException, } from "@nestjs/common"; import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; -import { ZodValidationPipe } from "nestjs-zod"; +import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; -import type { - Invoice, - InvoiceList, - InvoiceSsoLink, - InvoiceListQuery, +import type { Invoice, InvoiceList, InvoiceSsoLink } from "@customer-portal/domain/billing"; +import { + invoiceListQuerySchema, + invoiceListSchema, + invoiceSchema, + invoiceSsoLinkSchema, + invoiceSsoQuerySchema, + invoicePaymentLinkQuerySchema, } from "@customer-portal/domain/billing"; -import { invoiceListQuerySchema, invoiceSchema } from "@customer-portal/domain/billing"; import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { PaymentMethodList, PaymentGatewayList, InvoicePaymentLink, } from "@customer-portal/domain/payments"; +import { + paymentMethodListSchema, + paymentGatewayListSchema, + invoicePaymentLinkSchema, +} from "@customer-portal/domain/payments"; + +class InvoiceListQueryDto extends createZodDto(invoiceListQuerySchema) {} +class InvoiceListDto extends createZodDto(invoiceListSchema) {} +class InvoiceDto extends createZodDto(invoiceSchema) {} +class InvoiceSsoLinkDto extends createZodDto(invoiceSsoLinkSchema) {} +class InvoiceSsoQueryDto extends createZodDto(invoiceSsoQuerySchema) {} +class InvoicePaymentLinkQueryDto extends createZodDto(invoicePaymentLinkQuerySchema) {} +class PaymentMethodListDto extends createZodDto(paymentMethodListSchema) {} +class PaymentGatewayListDto extends createZodDto(paymentGatewayListSchema) {} +class InvoicePaymentLinkDto extends createZodDto(invoicePaymentLinkSchema) {} /** * Invoice Controller * - * All request validation is handled by Zod schemas via ZodValidationPipe. + * All request validation is handled by Zod schemas via global ZodValidationPipe. * Business logic is delegated to service layer. */ @Controller("invoices") @@ -45,14 +61,16 @@ export class InvoicesController { ) {} @Get() + @ZodResponse({ description: "List invoices", type: InvoiceListDto }) async getInvoices( @Request() req: RequestWithUser, - @Query(new ZodValidationPipe(invoiceListQuerySchema)) query: InvoiceListQuery + @Query() query: InvoiceListQueryDto ): Promise { return this.invoicesService.getInvoices(req.user.id, query); } @Get("payment-methods") + @ZodResponse({ description: "List payment methods", type: PaymentMethodListDto }) async getPaymentMethods(@Request() req: RequestWithUser): Promise { const mapping = await this.mappingsService.findByUserId(req.user.id); if (!mapping?.whmcsClientId) { @@ -62,12 +80,14 @@ export class InvoicesController { } @Get("payment-gateways") + @ZodResponse({ description: "List payment gateways", type: PaymentGatewayListDto }) async getPaymentGateways(): Promise { return this.whmcsService.getPaymentGateways(); } @Post("payment-methods/refresh") @HttpCode(HttpStatus.OK) + @ZodResponse({ description: "Refresh payment methods", type: PaymentMethodListDto }) async refreshPaymentMethods(@Request() req: RequestWithUser): Promise { // Invalidate cache first await this.whmcsService.invalidatePaymentMethodsCache(req.user.id); @@ -81,23 +101,19 @@ export class InvoicesController { } @Get(":id") + @ZodResponse({ description: "Get invoice by id", type: InvoiceDto }) async getInvoiceById( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) invoiceId: number ): Promise { - // Validate using domain schema - invoiceSchema.shape.id.parse(invoiceId); return this.invoicesService.getInvoiceById(req.user.id, invoiceId); } @Get(":id/subscriptions") getInvoiceSubscriptions( @Request() _req: RequestWithUser, - @Param("id", ParseIntPipe) invoiceId: number + @Param("id", ParseIntPipe) _invoiceId: number ): Subscription[] { - // Validate using domain schema - invoiceSchema.shape.id.parse(invoiceId); - // This functionality has been moved to WHMCS directly // For now, return empty array as subscriptions are managed in WHMCS return []; @@ -105,28 +121,23 @@ export class InvoicesController { @Post(":id/sso-link") @HttpCode(HttpStatus.OK) + @ZodResponse({ description: "Create invoice SSO link", type: InvoiceSsoLinkDto }) async createSsoLink( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) invoiceId: number, - @Query("target") target?: "view" | "download" | "pay" + @Query() query: InvoiceSsoQueryDto ): Promise { - // Validate using domain schema - invoiceSchema.shape.id.parse(invoiceId); - - // Validate target parameter - if (target && !["view", "download", "pay"].includes(target)) { - throw new BadRequestException('Target must be "view", "download", or "pay"'); - } - const mapping = await this.mappingsService.findByUserId(req.user.id); if (!mapping?.whmcsClientId) { throw new Error("WHMCS client mapping not found"); } + const parsedQuery = invoiceSsoQuerySchema.parse(query as unknown); + const ssoUrl = await this.whmcsService.whmcsSsoForInvoice( mapping.whmcsClientId, invoiceId, - target || "view" + parsedQuery.target ); return { @@ -137,36 +148,30 @@ export class InvoicesController { @Post(":id/payment-link") @HttpCode(HttpStatus.OK) + @ZodResponse({ description: "Create invoice payment link", type: InvoicePaymentLinkDto }) async createPaymentLink( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) invoiceId: number, - @Query("paymentMethodId") paymentMethodId?: string, - @Query("gatewayName") gatewayName?: string + @Query() query: InvoicePaymentLinkQueryDto ): Promise { - // Validate using domain schema - invoiceSchema.shape.id.parse(invoiceId); - - const paymentMethodIdNum = paymentMethodId ? parseInt(paymentMethodId, 10) : undefined; - if (paymentMethodId && (isNaN(paymentMethodIdNum!) || paymentMethodIdNum! <= 0)) { - throw new BadRequestException("Payment method ID must be a positive number"); - } - const mapping = await this.mappingsService.findByUserId(req.user.id); if (!mapping?.whmcsClientId) { throw new Error("WHMCS client mapping not found"); } + const parsedQuery = invoicePaymentLinkQuerySchema.parse(query as unknown); + const ssoResult = await this.whmcsService.createPaymentSsoToken( mapping.whmcsClientId, invoiceId, - paymentMethodIdNum, - gatewayName || "stripe" + parsedQuery.paymentMethodId, + parsedQuery.gatewayName ); return { url: ssoResult.url, expiresAt: ssoResult.expiresAt, - gatewayName: gatewayName || "stripe", + gatewayName: parsedQuery.gatewayName, }; } } diff --git a/apps/bff/src/modules/invoices/invoices.module.ts b/apps/bff/src/modules/invoices/invoices.module.ts index 6ad44d0b..9090feb5 100644 --- a/apps/bff/src/modules/invoices/invoices.module.ts +++ b/apps/bff/src/modules/invoices/invoices.module.ts @@ -10,7 +10,7 @@ import { InvoiceHealthService } from "./services/invoice-health.service.js"; /** * Invoice Module * - * Validation is now handled by Zod schemas via ZodValidationPipe in controller. + * Validation is handled by Zod schemas via Zod DTOs + the global ZodValidationPipe (APP_PIPE). * No separate validator service needed. */ @Module({ diff --git a/apps/bff/src/modules/notifications/notifications.controller.ts b/apps/bff/src/modules/notifications/notifications.controller.ts index 49c7fe45..4478ac26 100644 --- a/apps/bff/src/modules/notifications/notifications.controller.ts +++ b/apps/bff/src/modules/notifications/notifications.controller.ts @@ -4,23 +4,28 @@ * API endpoints for managing in-app notifications. */ -import { - Controller, - Get, - Post, - Param, - Query, - Req, - UseGuards, - ParseIntPipe, - DefaultValuePipe, - ParseBoolPipe, -} from "@nestjs/common"; +import { Controller, Get, Post, Param, Query, Req, UseGuards } from "@nestjs/common"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { NotificationService } from "./notifications.service.js"; -import type { NotificationListResponse } from "@customer-portal/domain/notifications"; -import type { ApiSuccessAckResponse } from "@customer-portal/domain/common"; +import { + notificationListResponseSchema, + notificationUnreadCountResponseSchema, + type NotificationListResponse, +} from "@customer-portal/domain/notifications"; +import { notificationQuerySchema } from "@customer-portal/domain/notifications"; +import { + apiSuccessAckResponseSchema, + type ApiSuccessAckResponse, +} from "@customer-portal/domain/common"; +import { createZodDto, ZodResponse } from "nestjs-zod"; + +class NotificationQueryDto extends createZodDto(notificationQuerySchema) {} +class NotificationListResponseDto extends createZodDto(notificationListResponseSchema) {} +class NotificationUnreadCountResponseDto extends createZodDto( + notificationUnreadCountResponseSchema +) {} +class ApiSuccessAckResponseDto extends createZodDto(apiSuccessAckResponseSchema) {} @Controller("notifications") @UseGuards(RateLimitGuard) @@ -32,17 +37,17 @@ export class NotificationsController { */ @Get() @RateLimit({ limit: 60, ttl: 60 }) + @ZodResponse({ description: "Get notifications", type: NotificationListResponseDto }) async getNotifications( @Req() req: RequestWithUser, - @Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, - @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, - @Query("includeRead", new DefaultValuePipe(true), ParseBoolPipe) - includeRead: boolean + @Query() query: NotificationQueryDto ): Promise { + const parsedQuery = notificationQuerySchema.parse(query as unknown); + return this.notificationService.getNotifications(req.user.id, { - limit: Math.min(limit, 50), // Cap at 50 - offset, - includeRead, + limit: Math.min(parsedQuery.limit, 50), // Cap at 50 + offset: parsedQuery.offset, + includeRead: parsedQuery.includeRead, }); } @@ -51,6 +56,7 @@ export class NotificationsController { */ @Get("unread-count") @RateLimit({ limit: 120, ttl: 60 }) + @ZodResponse({ description: "Get unread count", type: NotificationUnreadCountResponseDto }) async getUnreadCount(@Req() req: RequestWithUser): Promise<{ count: number }> { const count = await this.notificationService.getUnreadCount(req.user.id); return { count }; @@ -61,6 +67,7 @@ export class NotificationsController { */ @Post(":id/read") @RateLimit({ limit: 60, ttl: 60 }) + @ZodResponse({ description: "Mark as read", type: ApiSuccessAckResponseDto }) async markAsRead( @Req() req: RequestWithUser, @Param("id") notificationId: string @@ -74,6 +81,7 @@ export class NotificationsController { */ @Post("read-all") @RateLimit({ limit: 10, ttl: 60 }) + @ZodResponse({ description: "Mark all as read", type: ApiSuccessAckResponseDto }) async markAllAsRead(@Req() req: RequestWithUser): Promise { await this.notificationService.markAllAsRead(req.user.id); return { success: true }; @@ -84,6 +92,7 @@ export class NotificationsController { */ @Post(":id/dismiss") @RateLimit({ limit: 60, ttl: 60 }) + @ZodResponse({ description: "Dismiss notification", type: ApiSuccessAckResponseDto }) async dismiss( @Req() req: RequestWithUser, @Param("id") notificationId: string diff --git a/apps/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts index 773a8761..269994d4 100644 --- a/apps/bff/src/modules/orders/controllers/checkout.controller.ts +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -1,48 +1,26 @@ -import { - Body, - Controller, - Get, - Param, - Post, - Request, - UseGuards, - UsePipes, - Inject, -} from "@nestjs/common"; +import { Body, Controller, Get, Param, Post, Request, UseGuards, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { ZodValidationPipe } from "nestjs-zod"; +import { createZodDto, ZodResponse } from "nestjs-zod"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { CheckoutService } from "../services/checkout.service.js"; import { CheckoutSessionService } from "../services/checkout-session.service.js"; import { - checkoutItemSchema, checkoutCartSchema, checkoutBuildCartRequestSchema, checkoutBuildCartResponseSchema, - checkoutTotalsSchema, + checkoutSessionIdParamSchema, + checkoutSessionResponseSchema, + checkoutValidateCartResponseSchema, } from "@customer-portal/domain/orders"; -import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders"; -import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; -import { z } from "zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; -const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() })); -const checkoutSessionIdParamSchema = z.object({ sessionId: z.string().uuid() }); - -const checkoutCartSummarySchema = z.object({ - items: z.array(checkoutItemSchema), - totals: checkoutTotalsSchema, -}); - -const checkoutSessionResponseSchema = apiSuccessResponseSchema( - z.object({ - sessionId: z.string().uuid(), - expiresAt: z.string(), - orderType: z.enum(["Internet", "SIM", "VPN", "Other"]), - cart: checkoutCartSummarySchema, - }) -); +class CheckoutBuildCartRequestDto extends createZodDto(checkoutBuildCartRequestSchema) {} +class CheckoutSessionIdParamDto extends createZodDto(checkoutSessionIdParamSchema) {} +class CheckoutCartDto extends createZodDto(checkoutCartSchema) {} +class CheckoutBuildCartResponseDto extends createZodDto(checkoutBuildCartResponseSchema) {} +class CheckoutSessionResponseDto extends createZodDto(checkoutSessionResponseSchema) {} +class ValidateCartResponseDto extends createZodDto(checkoutValidateCartResponseSchema) {} @Controller("checkout") @Public() // Cart building and validation can be done without authentication @@ -55,8 +33,12 @@ export class CheckoutController { @Post("cart") @UseGuards(SalesforceReadThrottleGuard) - @UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema)) - async buildCart(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) { + @ZodResponse({ + status: 201, + description: "Build checkout cart", + type: CheckoutBuildCartResponseDto, + }) + async buildCart(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequestDto) { this.logger.log("Building checkout cart", { userId: req.user?.id, orderType: body.orderType, @@ -70,10 +52,7 @@ export class CheckoutController { req.user?.id ); - return checkoutBuildCartResponseSchema.parse({ - success: true, - data: cart, - }); + return { success: true as const, data: cart }; } catch (error) { this.logger.error("Failed to build checkout cart", { error: error instanceof Error ? error.message : String(error), @@ -90,8 +69,12 @@ export class CheckoutController { */ @Post("session") @UseGuards(SalesforceReadThrottleGuard) - @UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema)) - async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) { + @ZodResponse({ + status: 201, + description: "Create checkout session", + type: CheckoutSessionResponseDto, + }) + async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequestDto) { this.logger.log("Creating checkout session", { userId: req.user?.id, orderType: body.orderType, @@ -106,8 +89,8 @@ export class CheckoutController { const session = await this.checkoutSessions.createSession(body, cart); - return checkoutSessionResponseSchema.parse({ - success: true, + return { + success: true as const, data: { sessionId: session.sessionId, expiresAt: session.expiresAt, @@ -117,16 +100,20 @@ export class CheckoutController { totals: cart.totals, }, }, - }); + }; } @Get("session/:sessionId") @UseGuards(SalesforceReadThrottleGuard) - @UsePipes(new ZodValidationPipe(checkoutSessionIdParamSchema)) - async getSession(@Param() params: { sessionId: string }) { + @ZodResponse({ + status: 200, + description: "Get checkout session", + type: CheckoutSessionResponseDto, + }) + async getSession(@Param() params: CheckoutSessionIdParamDto) { const session = await this.checkoutSessions.getSession(params.sessionId); - return checkoutSessionResponseSchema.parse({ - success: true, + return { + success: true as const, data: { sessionId: params.sessionId, expiresAt: session.expiresAt, @@ -136,12 +123,16 @@ export class CheckoutController { totals: session.cart.totals, }, }, - }); + }; } @Post("validate") - @UsePipes(new ZodValidationPipe(checkoutCartSchema)) - validateCart(@Body() cart: CheckoutCart) { + @ZodResponse({ + status: 201, + description: "Validate checkout cart", + type: ValidateCartResponseDto, + }) + validateCart(@Body() cart: CheckoutCartDto) { this.logger.log("Validating checkout cart", { itemCount: cart.items.length, }); @@ -149,10 +140,7 @@ export class CheckoutController { try { this.checkoutService.validateCart(cart); - return validateCartResponseSchema.parse({ - success: true, - data: { valid: true }, - }); + return { success: true as const, data: { valid: true } }; } catch (error) { this.logger.error("Checkout cart validation failed", { error: error instanceof Error ? error.message : String(error), diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index 54883b84..16105bbe 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -7,7 +7,6 @@ import { Post, Request, Sse, - UsePipes, UseGuards, UnauthorizedException, type MessageEvent, @@ -16,13 +15,15 @@ import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; import { OrderOrchestrator } from "./services/order-orchestrator.service.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { Logger } from "nestjs-pino"; -import { ZodValidationPipe } from "nestjs-zod"; +import { createZodDto, ZodResponse } from "nestjs-zod"; import { + checkoutSessionCreateOrderRequestSchema, createOrderRequestSchema, orderCreateResponseSchema, sfOrderIdParamSchema, + orderDetailsSchema, + orderListResponseSchema, type CreateOrderRequest, - type SfOrderIdParam, } from "@customer-portal/domain/orders"; import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; import { Observable } from "rxjs"; @@ -31,11 +32,16 @@ import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js"; import { CheckoutService } from "./services/checkout.service.js"; import { CheckoutSessionService } from "./services/checkout-session.service.js"; -import { z } from "zod"; +import { SkipSuccessEnvelope } from "@bff/core/http/transform.interceptor.js"; -const checkoutSessionCreateOrderSchema = z.object({ - checkoutSessionId: z.string().uuid(), -}); +class CreateOrderRequestDto extends createZodDto(createOrderRequestSchema) {} +class CheckoutSessionCreateOrderDto extends createZodDto(checkoutSessionCreateOrderRequestSchema) {} +class SfOrderIdParamDto extends createZodDto(sfOrderIdParamSchema) {} +class CreateOrderResponseDto extends createZodDto( + apiSuccessResponseSchema(orderCreateResponseSchema) +) {} +class OrderDetailsDto extends createZodDto(orderDetailsSchema) {} +class OrderListResponseDto extends createZodDto(orderListResponseSchema) {} @Controller("orders") @UseGuards(RateLimitGuard) @@ -48,13 +54,11 @@ export class OrdersController { private readonly logger: Logger ) {} - private readonly createOrderResponseSchema = apiSuccessResponseSchema(orderCreateResponseSchema); - @Post() @UseGuards(SalesforceWriteThrottleGuard) @RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute - @UsePipes(new ZodValidationPipe(createOrderRequestSchema)) - async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) { + @ZodResponse({ status: 201, description: "Create order", type: CreateOrderResponseDto }) + async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequestDto) { this.logger.log( { userId: req.user?.id, @@ -66,7 +70,7 @@ export class OrdersController { try { const result = await this.orderOrchestrator.createOrder(req.user.id, body); - return this.createOrderResponseSchema.parse({ success: true, data: result }); + return { success: true as const, data: result }; } catch (error) { this.logger.error( { @@ -83,10 +87,14 @@ export class OrdersController { @Post("from-checkout-session") @UseGuards(SalesforceWriteThrottleGuard) @RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute - @UsePipes(new ZodValidationPipe(checkoutSessionCreateOrderSchema)) + @ZodResponse({ + status: 201, + description: "Create order from checkout session", + type: CreateOrderResponseDto, + }) async createFromCheckoutSession( @Request() req: RequestWithUser, - @Body() body: { checkoutSessionId: string } + @Body() body: CheckoutSessionCreateOrderDto ) { this.logger.log( { @@ -129,19 +137,20 @@ export class OrdersController { await this.checkoutSessions.deleteSession(body.checkoutSessionId); - return this.createOrderResponseSchema.parse({ success: true, data: result }); + return { success: true as const, data: result }; } @Get("user") @UseGuards(SalesforceReadThrottleGuard) + @ZodResponse({ description: "Get user orders", type: OrderListResponseDto }) async getUserOrders(@Request() req: RequestWithUser) { return this.orderOrchestrator.getOrdersForUser(req.user.id); } @Get(":sfOrderId") - @UsePipes(new ZodValidationPipe(sfOrderIdParamSchema)) @UseGuards(SalesforceReadThrottleGuard) - async get(@Request() req: RequestWithUser, @Param() params: SfOrderIdParam) { + @ZodResponse({ description: "Get order details", type: OrderDetailsDto }) + async get(@Request() req: RequestWithUser, @Param() params: SfOrderIdParamDto) { if (!req.user?.id) { throw new UnauthorizedException("Authentication required"); } @@ -149,11 +158,11 @@ export class OrdersController { } @Sse(":sfOrderId/events") - @UsePipes(new ZodValidationPipe(sfOrderIdParamSchema)) @UseGuards(SalesforceReadThrottleGuard) + @SkipSuccessEnvelope() async streamOrderUpdates( @Request() req: RequestWithUser, - @Param() params: SfOrderIdParam + @Param() params: SfOrderIdParamDto ): Promise> { // Ensure caller is allowed to access this order stream (avoid leaking existence) try { 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 477e5007..7a7a6693 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -9,6 +9,7 @@ import { OrderItemBuilder } from "./order-item-builder.service.js"; import type { OrderItemCompositePayload } from "./order-item-builder.service.js"; import { OrdersCacheService } from "./orders-cache.service.js"; import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders"; +import type { CreateOrderRequest } from "@customer-portal/domain/orders"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; @@ -35,12 +36,12 @@ export class OrderOrchestrator { /** * Create a new order - main entry point */ - async createOrder(userId: string, rawBody: unknown) { + async createOrder(userId: string, body: CreateOrderRequest) { this.logger.log({ userId }, "Order creation workflow started"); // 1) Complete validation (format + business rules) const { validatedBody, userMapping, pricebookId } = - await this.orderValidator.validateCompleteOrder(userId, rawBody); + await this.orderValidator.validateCompleteOrder(userId, body); this.logger.log( { 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 e3414932..80300dbc 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -5,7 +5,6 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { - createOrderRequestSchema, orderWithSkuValidationSchema, type CreateOrderRequest, type OrderBusinessValidation, @@ -38,48 +37,6 @@ export class OrderValidator { private readonly residenceCards: ResidenceCardService ) {} - /** - * Validate request format and structure using direct Zod validation - */ - validateRequestFormat(rawBody: unknown): CreateOrderRequest { - try { - this.logger.debug({ bodyType: typeof rawBody }, "Starting request format validation"); - - // Use direct Zod validation with .parse() - throws ZodError on failure - const validatedBody = createOrderRequestSchema.parse(rawBody); - - this.logger.debug( - { - orderType: validatedBody.orderType, - skuCount: validatedBody.skus.length, - hasConfigurations: !!validatedBody.configurations, - }, - "Request format validation passed" - ); - - return validatedBody; - } catch (error) { - if (error instanceof ZodError) { - const errorMessages = error.issues.map(issue => { - const path = issue.path.join("."); - return path ? `${path}: ${issue.message}` : issue.message; - }); - - this.logger.error({ errors: errorMessages }, "Zod validation failed"); - - throw new BadRequestException({ - message: "Order validation failed", - errors: errorMessages, - statusCode: 400, - }); - } - - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error({ error: errorMessage }, "Request format validation failed"); - throw error; - } - } - /** * Validate user mapping exists - simple business logic */ @@ -222,7 +179,7 @@ export class OrderValidator { */ async validateCompleteOrder( userId: string, - rawBody: unknown + body: CreateOrderRequest ): Promise<{ validatedBody: OrderBusinessValidation; userMapping: { userId: string; sfAccountId?: string; whmcsClientId: number }; @@ -230,8 +187,8 @@ export class OrderValidator { }> { this.logger.log({ userId }, "Starting complete order validation"); - // 1. Format validation (replaces DTO validation) - const validatedBody = this.validateRequestFormat(rawBody); + // 1. Format validation is performed in the controller layer via Zod DTO + global pipe. + const validatedBody = body; // 1b. Business validation (ensures userId-specific constraints) let businessValidatedBody: OrderBusinessValidation; diff --git a/apps/bff/src/modules/realtime/realtime.controller.ts b/apps/bff/src/modules/realtime/realtime.controller.ts index 44e86a5e..954480b5 100644 --- a/apps/bff/src/modules/realtime/realtime.controller.ts +++ b/apps/bff/src/modules/realtime/realtime.controller.ts @@ -18,6 +18,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { Logger } from "nestjs-pino"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import { RealtimeConnectionLimiterService } from "./realtime-connection-limiter.service.js"; +import { SkipSuccessEnvelope } from "@bff/core/http/transform.interceptor.js"; @Controller("events") export class RealtimeController { @@ -39,6 +40,7 @@ export class RealtimeController { @RateLimit({ limit: 30, ttl: 60 }) // protect against reconnect storms / refresh spam @Header("Cache-Control", "private, no-store") @Header("X-Accel-Buffering", "no") // nginx: disable response buffering for SSE + @SkipSuccessEnvelope() async stream(@Request() req: RequestWithUser): Promise> { if (!this.limiter.tryAcquire(req.user.id)) { throw new HttpException( diff --git a/apps/bff/src/modules/services/internet-eligibility.controller.ts b/apps/bff/src/modules/services/internet-eligibility.controller.ts index 7b45dfb7..6ed0d283 100644 --- a/apps/bff/src/modules/services/internet-eligibility.controller.ts +++ b/apps/bff/src/modules/services/internet-eligibility.controller.ts @@ -1,18 +1,12 @@ -import { Body, Controller, Get, Header, Post, Req, UseGuards, UsePipes } from "@nestjs/common"; -import { ZodValidationPipe } from "nestjs-zod"; -import { z } from "zod"; +import { Body, Controller, Get, Header, Post, Req, UseGuards } from "@nestjs/common"; +import { createZodDto } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import { InternetServicesService } from "./services/internet-services.service.js"; -import { addressSchema } from "@customer-portal/domain/customer"; import type { InternetEligibilityDetails } from "@customer-portal/domain/services"; +import { internetEligibilityRequestSchema } from "@customer-portal/domain/services"; -const eligibilityRequestSchema = z.object({ - notes: z.string().trim().max(2000).optional(), - address: addressSchema.partial().optional(), -}); - -type EligibilityRequest = z.infer; +class EligibilityRequestDto extends createZodDto(internetEligibilityRequestSchema) {} /** * Internet Eligibility Controller @@ -38,11 +32,10 @@ export class InternetEligibilityController { @Post("eligibility-request") @RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP - @UsePipes(new ZodValidationPipe(eligibilityRequestSchema)) @Header("Cache-Control", "private, no-store") async requestEligibility( @Req() req: RequestWithUser, - @Body() body: EligibilityRequest + @Body() body: EligibilityRequestDto ): Promise<{ requestId: string }> { const requestId = await this.internetCatalog.requestEligibilityCheckForUser(req.user.id, { email: req.user.email, diff --git a/apps/bff/src/modules/subscriptions/sim-orders.controller.ts b/apps/bff/src/modules/subscriptions/sim-orders.controller.ts index 496ecc99..25bd0f45 100644 --- a/apps/bff/src/modules/subscriptions/sim-orders.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-orders.controller.ts @@ -1,21 +1,19 @@ -import { Body, Controller, Post, Request, UsePipes, Headers } from "@nestjs/common"; +import { Body, Controller, Post, Request, Headers } from "@nestjs/common"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { SimOrderActivationService } from "./sim-order-activation.service.js"; -import { ZodValidationPipe } from "nestjs-zod"; -import { - simOrderActivationRequestSchema, - type SimOrderActivationRequest, -} from "@customer-portal/domain/sim"; +import { createZodDto } from "nestjs-zod"; +import { simOrderActivationRequestSchema } from "@customer-portal/domain/sim"; + +class SimOrderActivationRequestDto extends createZodDto(simOrderActivationRequestSchema) {} @Controller("subscriptions/sim/orders") export class SimOrdersController { constructor(private readonly activation: SimOrderActivationService) {} @Post("activate") - @UsePipes(new ZodValidationPipe(simOrderActivationRequestSchema)) async activate( @Request() req: RequestWithUser, - @Body() body: SimOrderActivationRequest, + @Body() body: SimOrderActivationRequestDto, @Headers("x-idempotency-key") idempotencyKey?: string ) { const result = await this.activation.activate(req.user.id, body, idempotencyKey); diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index dda9dbe3..6870a6ff 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -7,8 +7,6 @@ import { Body, Request, ParseIntPipe, - BadRequestException, - UsePipes, Header, UseGuards, } from "@nestjs/common"; @@ -17,41 +15,51 @@ import { SubscriptionsService } from "./subscriptions.service.js"; import { SimManagementService } from "./sim-management.service.js"; import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.service.js"; -import { subscriptionQuerySchema } from "@customer-portal/domain/subscriptions"; +import { + subscriptionQuerySchema, + subscriptionListSchema, + subscriptionSchema, + subscriptionStatsSchema, + simActionResponseSchema, + simPlanChangeResultSchema, + internetCancellationPreviewSchema, +} from "@customer-portal/domain/subscriptions"; import type { Subscription, SubscriptionList, SubscriptionStats, SimActionResponse, SimPlanChangeResult, - SubscriptionQuery, } from "@customer-portal/domain/subscriptions"; import type { InvoiceList } from "@customer-portal/domain/billing"; import type { ApiSuccessResponse } from "@customer-portal/domain/common"; +import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; import { createPaginationSchema } from "@customer-portal/domain/toolkit/validation/helpers"; -import type { z } from "zod"; import { simTopupRequestSchema, simChangePlanRequestSchema, simCancelRequestSchema, simFeaturesRequestSchema, + simTopUpHistoryRequestSchema, simCancelFullRequestSchema, simChangePlanFullRequestSchema, simReissueFullRequestSchema, - type SimTopupRequest, - type SimChangePlanRequest, - type SimCancelRequest, - type SimFeaturesRequest, - type SimCancelFullRequest, - type SimChangePlanFullRequest, + simHistoryQuerySchema, + simSftpListQuerySchema, + simCallHistoryImportQuerySchema, + simTopUpPricingPreviewRequestSchema, + simReissueEsimRequestSchema, + simInfoSchema, + simDetailsSchema, + simUsageSchema, + simTopUpHistorySchema, type SimAvailablePlan, type SimCancellationPreview, type SimDomesticCallHistoryResponse, type SimInternationalCallHistoryResponse, type SimSmsHistoryResponse, - type SimReissueFullRequest, } from "@customer-portal/domain/sim"; -import { ZodValidationPipe } from "nestjs-zod"; +import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { SimPlanService } from "./sim-management/services/sim-plan.service.js"; import { SimCancellationService } from "./sim-management/services/sim-cancellation.service.js"; @@ -61,16 +69,48 @@ import { SimCallHistoryService } from "./sim-management/services/sim-call-histor import { InternetCancellationService } from "./internet-management/services/internet-cancellation.service.js"; import { internetCancelRequestSchema, - type InternetCancelRequest, type SimActionResponse as SubscriptionActionResponse, } from "@customer-portal/domain/subscriptions"; +import { invoiceListSchema } from "@customer-portal/domain/billing"; const subscriptionInvoiceQuerySchema = createPaginationSchema({ defaultLimit: 10, maxLimit: 100, minLimit: 1, }); -type SubscriptionInvoiceQuery = z.infer; + +class SubscriptionQueryDto extends createZodDto(subscriptionQuerySchema) {} +class SubscriptionInvoiceQueryDto extends createZodDto(subscriptionInvoiceQuerySchema) {} +class SimTopupRequestDto extends createZodDto(simTopupRequestSchema) {} +class SimChangePlanRequestDto extends createZodDto(simChangePlanRequestSchema) {} +class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {} +class SimFeaturesRequestDto extends createZodDto(simFeaturesRequestSchema) {} +class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {} +class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {} +class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {} +class InternetCancelRequestDto extends createZodDto(internetCancelRequestSchema) {} + +class SimHistoryQueryDto extends createZodDto(simHistoryQuerySchema) {} +class SimSftpListQueryDto extends createZodDto(simSftpListQuerySchema) {} +class SimCallHistoryImportQueryDto extends createZodDto(simCallHistoryImportQuerySchema) {} +class SimTopUpPricingPreviewRequestDto extends createZodDto(simTopUpPricingPreviewRequestSchema) {} +class SimReissueEsimRequestDto extends createZodDto(simReissueEsimRequestSchema) {} +class SimTopUpHistoryRequestDto extends createZodDto(simTopUpHistoryRequestSchema) {} + +class SimInfoDto extends createZodDto(simInfoSchema) {} +class SimDetailsDto extends createZodDto(simDetailsSchema) {} +class SimUsageDto extends createZodDto(simUsageSchema) {} +class SimTopUpHistoryDto extends createZodDto(simTopUpHistorySchema) {} + +class SubscriptionListDto extends createZodDto(subscriptionListSchema) {} +class SubscriptionDto extends createZodDto(subscriptionSchema) {} +class SubscriptionStatsDto extends createZodDto(subscriptionStatsSchema) {} +class SimActionResponseDto extends createZodDto(simActionResponseSchema) {} +class SimPlanChangeResultDto extends createZodDto(simPlanChangeResultSchema) {} +class InvoiceListDto extends createZodDto(invoiceListSchema) {} +class InternetCancellationPreviewResponseDto extends createZodDto( + apiSuccessResponseSchema(internetCancellationPreviewSchema) +) {} @Controller("subscriptions") export class SubscriptionsController { @@ -87,10 +127,10 @@ export class SubscriptionsController { @Get() @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific - @UsePipes(new ZodValidationPipe(subscriptionQuerySchema)) + @ZodResponse({ description: "List subscriptions", type: SubscriptionListDto }) async getSubscriptions( @Request() req: RequestWithUser, - @Query() query: SubscriptionQuery + @Query() query: SubscriptionQueryDto ): Promise { const { status } = query; return this.subscriptionsService.getSubscriptions(req.user.id, { status }); @@ -98,12 +138,14 @@ export class SubscriptionsController { @Get("active") @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific + @ZodResponse({ description: "List active subscriptions", type: [SubscriptionDto] }) async getActiveSubscriptions(@Request() req: RequestWithUser): Promise { return this.subscriptionsService.getActiveSubscriptions(req.user.id); } @Get("stats") @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific + @ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto }) async getSubscriptionStats(@Request() req: RequestWithUser): Promise { return this.subscriptionsService.getSubscriptionStats(req.user.id); } @@ -126,12 +168,10 @@ export class SubscriptionsController { */ @UseGuards(AdminGuard) @Get("sim/call-history/sftp-files") - async listSftpFiles(@Query("path") path: string = "/home/PASI") { - if (!path.startsWith("/home/PASI")) { - throw new BadRequestException("Invalid path"); - } - const files = await this.simCallHistoryService.listSftpFiles(path); - return { success: true, data: files, path }; + async listSftpFiles(@Query() query: SimSftpListQueryDto) { + const parsedQuery = simSftpListQuerySchema.parse(query as unknown); + const files = await this.simCallHistoryService.listSftpFiles(parsedQuery.path); + return { success: true, data: files, path: parsedQuery.path }; } /** @@ -139,12 +179,9 @@ export class SubscriptionsController { */ @UseGuards(AdminGuard) @Post("sim/call-history/import") - async importCallHistory(@Query("month") yearMonth: string) { - if (!yearMonth || !/^\d{6}$/.test(yearMonth)) { - throw new BadRequestException("Invalid month format (expected YYYYMM)"); - } - - const result = await this.simCallHistoryService.importCallHistory(yearMonth); + async importCallHistory(@Query() query: SimCallHistoryImportQueryDto) { + const parsedQuery = simCallHistoryImportQuerySchema.parse(query as unknown); + const result = await this.simCallHistoryService.importCallHistory(parsedQuery.month); return { success: true, message: `Imported ${result.domestic} domestic calls, ${result.international} international calls, ${result.sms} SMS`, @@ -161,13 +198,8 @@ export class SubscriptionsController { @Get("sim/top-up/pricing/preview") @Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing calculation is deterministic - async previewSimTopUpPricing(@Query("quotaMb") quotaMb: string) { - const quotaMbNum = parseInt(quotaMb, 10); - if (isNaN(quotaMbNum) || quotaMbNum <= 0) { - throw new BadRequestException("Invalid quotaMb parameter"); - } - - const preview = await this.simTopUpPricingService.calculatePricingPreview(quotaMbNum); + async previewSimTopUpPricing(@Query() query: SimTopUpPricingPreviewRequestDto) { + const preview = await this.simTopUpPricingService.calculatePricingPreview(query.quotaMb); return { success: true, data: preview }; } @@ -181,6 +213,7 @@ export class SubscriptionsController { @Get(":id") @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific + @ZodResponse({ description: "Get subscription", type: SubscriptionDto }) async getSubscriptionById( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -190,10 +223,11 @@ export class SubscriptionsController { @Get(":id/invoices") @Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments + @ZodResponse({ description: "Get subscription invoices", type: InvoiceListDto }) async getSubscriptionInvoices( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery + @Query() query: SubscriptionInvoiceQueryDto ): Promise { return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query); } @@ -210,6 +244,7 @@ export class SubscriptionsController { } @Get(":id/sim") + @ZodResponse({ description: "Get SIM info", type: SimInfoDto }) async getSimInfo( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -218,6 +253,7 @@ export class SubscriptionsController { } @Get(":id/sim/details") + @ZodResponse({ description: "Get SIM details", type: SimDetailsDto }) async getSimDetails( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -226,6 +262,7 @@ export class SubscriptionsController { } @Get(":id/sim/usage") + @ZodResponse({ description: "Get SIM usage", type: SimUsageDto }) async getSimUsage( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -234,39 +271,32 @@ export class SubscriptionsController { } @Get(":id/sim/top-up-history") + @ZodResponse({ description: "Get SIM top-up history", type: SimTopUpHistoryDto }) async getSimTopUpHistory( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Query("fromDate") fromDate: string, - @Query("toDate") toDate: string + @Query() query: SimTopUpHistoryRequestDto ) { - if (!fromDate || !toDate) { - throw new BadRequestException("fromDate and toDate are required"); - } - - return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, { - fromDate, - toDate, - }); + return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, query); } @Post(":id/sim/top-up") - @UsePipes(new ZodValidationPipe(simTopupRequestSchema)) + @ZodResponse({ description: "Top up SIM", type: SimActionResponseDto }) async topUpSim( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: SimTopupRequest + @Body() body: SimTopupRequestDto ): Promise { await this.simManagementService.topUpSim(req.user.id, subscriptionId, body); return { success: true, message: "SIM top-up completed successfully" }; } @Post(":id/sim/change-plan") - @UsePipes(new ZodValidationPipe(simChangePlanRequestSchema)) + @ZodResponse({ description: "Change SIM plan", type: SimPlanChangeResultDto }) async changeSimPlan( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: SimChangePlanRequest + @Body() body: SimChangePlanRequestDto ): Promise { const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body); return { @@ -277,11 +307,11 @@ export class SubscriptionsController { } @Post(":id/sim/cancel") - @UsePipes(new ZodValidationPipe(simCancelRequestSchema)) + @ZodResponse({ description: "Cancel SIM", type: SimActionResponseDto }) async cancelSim( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: SimCancelRequest + @Body() body: SimCancelRequestDto ): Promise { await this.simManagementService.cancelSim(req.user.id, subscriptionId, body); return { success: true, message: "SIM cancellation completed successfully" }; @@ -291,18 +321,23 @@ export class SubscriptionsController { async reissueEsimProfile( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: { newEid?: string } = {} + @Body() body: SimReissueEsimRequestDto ): Promise { - await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid); + const parsedBody = simReissueEsimRequestSchema.parse(body as unknown); + await this.simManagementService.reissueEsimProfile( + req.user.id, + subscriptionId, + parsedBody.newEid + ); return { success: true, message: "eSIM profile reissue completed successfully" }; } @Post(":id/sim/features") - @UsePipes(new ZodValidationPipe(simFeaturesRequestSchema)) + @ZodResponse({ description: "Update SIM features", type: SimActionResponseDto }) async updateSimFeatures( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: SimFeaturesRequest + @Body() body: SimFeaturesRequestDto ): Promise { await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body); return { success: true, message: "SIM features updated successfully" }; @@ -327,11 +362,11 @@ export class SubscriptionsController { * Change SIM plan with enhanced flow (Salesforce SKU mapping + email notifications) */ @Post(":id/sim/change-plan-full") - @UsePipes(new ZodValidationPipe(simChangePlanFullRequestSchema)) + @ZodResponse({ description: "Change SIM plan (full)", type: SimPlanChangeResultDto }) async changeSimPlanFull( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: SimChangePlanFullRequest + @Body() body: SimChangePlanFullRequestDto ): Promise { const result = await this.simPlanService.changeSimPlanFull(req.user.id, subscriptionId, body); return { @@ -361,11 +396,11 @@ export class SubscriptionsController { * Cancel SIM with full flow (PA02-04 + email notifications) */ @Post(":id/sim/cancel-full") - @UsePipes(new ZodValidationPipe(simCancelFullRequestSchema)) + @ZodResponse({ description: "Cancel SIM (full)", type: SimActionResponseDto }) async cancelSimFull( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: SimCancelFullRequest + @Body() body: SimCancelFullRequestDto ): Promise { await this.simCancellationService.cancelSimFull(req.user.id, subscriptionId, body); return { @@ -378,11 +413,11 @@ export class SubscriptionsController { * Reissue SIM (both eSIM and physical SIM) */ @Post(":id/sim/reissue") - @UsePipes(new ZodValidationPipe(simReissueFullRequestSchema)) + @ZodResponse({ description: "Reissue SIM", type: SimActionResponseDto }) async reissueSim( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: SimReissueFullRequest + @Body() body: SimReissueFullRequestDto ): Promise { await this.esimManagementService.reissueSim(req.user.id, subscriptionId, body); @@ -403,6 +438,10 @@ export class SubscriptionsController { */ @Get(":id/internet/cancellation-preview") @Header("Cache-Control", "private, max-age=60") + @ZodResponse({ + description: "Get internet cancellation preview", + type: InternetCancellationPreviewResponseDto, + }) async getInternetCancellationPreview( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number @@ -411,18 +450,18 @@ export class SubscriptionsController { req.user.id, subscriptionId ); - return { success: true, data: preview }; + return { success: true as const, data: preview }; } /** * Submit Internet cancellation request */ @Post(":id/internet/cancel") - @UsePipes(new ZodValidationPipe(internetCancelRequestSchema)) + @ZodResponse({ description: "Cancel internet", type: SimActionResponseDto }) async cancelInternet( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: InternetCancelRequest + @Body() body: InternetCancelRequestDto ): Promise { await this.internetCancellationService.submitCancellation(req.user.id, subscriptionId, body); return { @@ -441,26 +480,15 @@ export class SubscriptionsController { async getDomesticCallHistory( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Query("month") month?: string, - @Query("page") page?: string, - @Query("limit") limit?: string + @Query() query: SimHistoryQueryDto ): Promise> { - const pageNum = parseInt(page || "1", 10); - const limitNum = parseInt(limit || "50", 10); - - if (isNaN(pageNum) || pageNum < 1) { - throw new BadRequestException("Invalid page number"); - } - if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { - throw new BadRequestException("Invalid limit (must be 1-100)"); - } - + const parsedQuery = simHistoryQuerySchema.parse(query as unknown); const result = await this.simCallHistoryService.getDomesticCallHistory( req.user.id, subscriptionId, - month, - pageNum, - limitNum + parsedQuery.month, + parsedQuery.page, + parsedQuery.limit ); return { success: true, data: result }; } @@ -473,26 +501,15 @@ export class SubscriptionsController { async getInternationalCallHistory( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Query("month") month?: string, - @Query("page") page?: string, - @Query("limit") limit?: string + @Query() query: SimHistoryQueryDto ): Promise> { - const pageNum = parseInt(page || "1", 10); - const limitNum = parseInt(limit || "50", 10); - - if (isNaN(pageNum) || pageNum < 1) { - throw new BadRequestException("Invalid page number"); - } - if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { - throw new BadRequestException("Invalid limit (must be 1-100)"); - } - + const parsedQuery = simHistoryQuerySchema.parse(query as unknown); const result = await this.simCallHistoryService.getInternationalCallHistory( req.user.id, subscriptionId, - month, - pageNum, - limitNum + parsedQuery.month, + parsedQuery.page, + parsedQuery.limit ); return { success: true, data: result }; } @@ -505,26 +522,15 @@ export class SubscriptionsController { async getSmsHistory( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Query("month") month?: string, - @Query("page") page?: string, - @Query("limit") limit?: string + @Query() query: SimHistoryQueryDto ): Promise> { - const pageNum = parseInt(page || "1", 10); - const limitNum = parseInt(limit || "50", 10); - - if (isNaN(pageNum) || pageNum < 1) { - throw new BadRequestException("Invalid page number"); - } - if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { - throw new BadRequestException("Invalid limit (must be 1-100)"); - } - + const parsedQuery = simHistoryQuerySchema.parse(query as unknown); const result = await this.simCallHistoryService.getSmsHistory( req.user.id, subscriptionId, - month, - pageNum, - limitNum + parsedQuery.month, + parsedQuery.page, + parsedQuery.limit ); return { success: true, data: result }; } diff --git a/apps/bff/src/modules/support/support.controller.ts b/apps/bff/src/modules/support/support.controller.ts index e2fc41c8..eb3ed12b 100644 --- a/apps/bff/src/modules/support/support.controller.ts +++ b/apps/bff/src/modules/support/support.controller.ts @@ -11,23 +11,32 @@ import { } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SupportService } from "./support.service.js"; -import { ZodValidationPipe } from "nestjs-zod"; +import { createZodDto, ZodResponse } from "nestjs-zod"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import { supportCaseFilterSchema, + supportCaseListSchema, + supportCaseSchema, createCaseRequestSchema, + createCaseResponseSchema, publicContactRequestSchema, - type SupportCaseFilter, type SupportCaseList, type SupportCase, - type CreateCaseRequest, type CreateCaseResponse, - type PublicContactRequest, } from "@customer-portal/domain/support"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { hashEmailForLogs } from "./support.logging.js"; import type { ApiSuccessMessageResponse } from "@customer-portal/domain/common"; +import { apiSuccessMessageResponseSchema } from "@customer-portal/domain/common"; + +class SupportCaseFilterDto extends createZodDto(supportCaseFilterSchema) {} +class CreateCaseRequestDto extends createZodDto(createCaseRequestSchema) {} +class PublicContactRequestDto extends createZodDto(publicContactRequestSchema) {} +class SupportCaseListDto extends createZodDto(supportCaseListSchema) {} +class SupportCaseDto extends createZodDto(supportCaseSchema) {} +class CreateCaseResponseDto extends createZodDto(createCaseResponseSchema) {} +class ApiSuccessMessageResponseDto extends createZodDto(apiSuccessMessageResponseSchema) {} @Controller("support") export class SupportController { @@ -37,15 +46,16 @@ export class SupportController { ) {} @Get("cases") + @ZodResponse({ description: "List support cases", type: SupportCaseListDto }) async listCases( @Request() req: RequestWithUser, - @Query(new ZodValidationPipe(supportCaseFilterSchema)) - filters: SupportCaseFilter + @Query() filters: SupportCaseFilterDto ): Promise { return this.supportService.listCases(req.user.id, filters); } @Get("cases/:id") + @ZodResponse({ description: "Get support case", type: SupportCaseDto }) async getCase( @Request() req: RequestWithUser, @Param("id") caseId: string @@ -54,10 +64,10 @@ export class SupportController { } @Post("cases") + @ZodResponse({ description: "Create support case", type: CreateCaseResponseDto }) async createCase( @Request() req: RequestWithUser, - @Body(new ZodValidationPipe(createCaseRequestSchema)) - body: CreateCaseRequest + @Body() body: CreateCaseRequestDto ): Promise { return this.supportService.createCase(req.user.id, body); } @@ -72,17 +82,18 @@ export class SupportController { @Public() @UseGuards(RateLimitGuard) @RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes - async publicContact( - @Body(new ZodValidationPipe(publicContactRequestSchema)) - body: PublicContactRequest - ): Promise { + @ZodResponse({ + description: "Public contact form submission", + type: ApiSuccessMessageResponseDto, + }) + async publicContact(@Body() body: PublicContactRequestDto): Promise { this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) }); try { await this.supportService.createPublicContactRequest(body); return { - success: true, + success: true as const, message: "Your message has been received. We will get back to you within 24 hours.", }; } catch (error) { diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index c20375de..cec11e9c 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -6,20 +6,24 @@ import { Req, UseInterceptors, ClassSerializerInterceptor, - UsePipes, UseGuards, } from "@nestjs/common"; import { UsersFacade } from "./application/users.facade.js"; -import { ZodValidationPipe } from "nestjs-zod"; -import { - updateCustomerProfileRequestSchema, - type UpdateCustomerProfileRequest, -} from "@customer-portal/domain/auth"; -import { addressSchema } from "@customer-portal/domain/customer"; +import { createZodDto, ZodResponse, ZodSerializerDto } from "nestjs-zod"; +import { updateCustomerProfileRequestSchema } from "@customer-portal/domain/auth"; +import { dashboardSummarySchema } from "@customer-portal/domain/dashboard"; +import { addressSchema, userSchema } from "@customer-portal/domain/customer"; import type { Address } from "@customer-portal/domain/customer"; +import type { User } from "@customer-portal/domain/customer"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; +class UpdateAddressDto extends createZodDto(addressSchema.partial()) {} +class UpdateCustomerProfileRequestDto extends createZodDto(updateCustomerProfileRequestSchema) {} +class AddressDto extends createZodDto(addressSchema) {} +class UserDto extends createZodDto(userSchema) {} +class DashboardSummaryDto extends createZodDto(dashboardSummarySchema) {} + @Controller("me") @UseInterceptors(ClassSerializerInterceptor) export class UsersController { @@ -31,8 +35,10 @@ export class UsersController { */ @UseGuards(SalesforceReadThrottleGuard) @Get() - async getProfile(@Req() req: RequestWithUser) { - return this.usersFacade.findById(req.user.id); + @ZodResponse({ description: "Get user profile", type: UserDto }) + async getProfile(@Req() req: RequestWithUser): Promise { + // This endpoint represents the authenticated user; treat missing user as an error. + return this.usersFacade.getProfile(req.user.id); } /** @@ -40,6 +46,7 @@ export class UsersController { */ @UseGuards(SalesforceReadThrottleGuard) @Get("summary") + @ZodResponse({ description: "Get dashboard summary", type: DashboardSummaryDto }) async getSummary(@Req() req: RequestWithUser) { return this.usersFacade.getUserSummary(req.user.id); } @@ -49,6 +56,7 @@ export class UsersController { */ @UseGuards(SalesforceReadThrottleGuard) @Get("address") + @ZodSerializerDto(addressSchema.nullable()) async getAddress(@Req() req: RequestWithUser): Promise
{ return this.usersFacade.getAddress(req.user.id); } @@ -57,10 +65,10 @@ export class UsersController { * PATCH /me/address - Update address fields */ @Patch("address") - @UsePipes(new ZodValidationPipe(addressSchema.partial())) + @ZodResponse({ description: "Update address", type: AddressDto }) async updateAddress( @Req() req: RequestWithUser, - @Body() address: Partial
+ @Body() address: UpdateAddressDto ): Promise
{ return this.usersFacade.updateAddress(req.user.id, address); } @@ -76,10 +84,10 @@ export class UsersController { * - Update both: { firstname: "John", address1: "123 Main St" } */ @Patch() - @UsePipes(new ZodValidationPipe(updateCustomerProfileRequestSchema)) + @ZodResponse({ description: "Update profile", type: UserDto }) async updateProfile( @Req() req: RequestWithUser, - @Body() updateData: UpdateCustomerProfileRequest + @Body() updateData: UpdateCustomerProfileRequestDto ) { return this.usersFacade.updateProfile(req.user.id, updateData); } diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index 4202cfe5..85d7b2ee 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -13,6 +13,7 @@ export * from "./response-helpers"; // Import createClient for internal use import { createClient, ApiError } from "./runtime/client"; +import { parseDomainError } from "./response-helpers"; import { logger } from "@/lib/logger"; /** @@ -45,6 +46,11 @@ function isAuthEndpoint(url: string): boolean { * Handles both `{ message }` and `{ error: { message } }` formats */ function extractErrorMessage(body: unknown): string | null { + const domainError = parseDomainError(body); + if (domainError) { + return domainError.error.message; + } + if (!body || typeof body !== "object") { return null; } diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts index aefda618..90dfd463 100644 --- a/apps/portal/src/lib/api/runtime/client.ts +++ b/apps/portal/src/lib/api/runtime/client.ts @@ -1,4 +1,4 @@ -import type { ApiResponse } from "../response-helpers"; +import { parseDomainError, type ApiResponse } from "../response-helpers"; import { logger } from "@/lib/logger"; export class ApiError extends Error { @@ -132,6 +132,11 @@ const getBodyMessage = (body: unknown): string | null => { return body; } + const domainError = parseDomainError(body); + if (domainError) { + return domainError.error.message; + } + if (body && typeof body === "object" && "message" in body) { const maybeMessage = (body as { message?: unknown }).message; if (typeof maybeMessage === "string") { diff --git a/apps/portal/src/lib/utils/error-handling.ts b/apps/portal/src/lib/utils/error-handling.ts index f8be93bb..3236e8c7 100644 --- a/apps/portal/src/lib/utils/error-handling.ts +++ b/apps/portal/src/lib/utils/error-handling.ts @@ -12,6 +12,7 @@ import { ErrorMetadata, type ErrorCodeType, } from "@customer-portal/domain/common"; +import { parseDomainError } from "@/lib/api"; // ============================================================================ // Types @@ -69,29 +70,20 @@ function parseApiError(error: ClientApiError): ParsedError { const body = error.body; const status = error.response?.status; - // Try to extract from standard API error response format - if (body && typeof body === "object") { - const bodyObj = body as Record; + const domainError = parseDomainError(body); + if (domainError) { + const rawCode = domainError.error.code; + const resolvedCode: ErrorCodeType = Object.prototype.hasOwnProperty.call(ErrorMetadata, rawCode) + ? (rawCode as ErrorCodeType) + : ErrorCode.UNKNOWN; - // Check for standard { success: false, error: { code, message } } format - if (bodyObj.success === false && bodyObj.error && typeof bodyObj.error === "object") { - const errorObj = bodyObj.error as Record; - const code = typeof errorObj.code === "string" ? errorObj.code : undefined; - const message = typeof errorObj.message === "string" ? errorObj.message : undefined; - - if (code && message) { - const metadata = ErrorMetadata[code as ErrorCodeType] ?? ErrorMetadata[ErrorCode.UNKNOWN]; - return { - code: code as ErrorCodeType, - message, - shouldLogout: metadata.shouldLogout, - shouldRetry: metadata.shouldRetry, - }; - } - } - - // No message-based code inference. If the response doesn't include a structured error code, - // we fall back to status-based mapping below. + const metadata = ErrorMetadata[resolvedCode] ?? ErrorMetadata[ErrorCode.UNKNOWN]; + return { + code: resolvedCode, + message: domainError.error.message, + shouldLogout: metadata.shouldLogout, + shouldRetry: metadata.shouldRetry, + }; } // Fall back to status code mapping diff --git a/docs/README.md b/docs/README.md index b3e841f7..ef1af176 100644 --- a/docs/README.md +++ b/docs/README.md @@ -101,11 +101,12 @@ Feature guides explaining how the portal functions: ### BFF (Backend for Frontend) -| Document | Description | -| ----------------------------------------------------------------- | --------------------------- | -| [Integration Patterns](./development/bff/integration-patterns.md) | Clean architecture patterns | -| [DB Mappers](./development/bff/db-mappers.md) | Database mapping | -| [Order Status Updates](./development/bff/order-status-updates.md) | Status update strategy | +| Document | Description | +| ----------------------------------------------------------------- | ---------------------------- | +| [Integration Patterns](./development/bff/integration-patterns.md) | Clean architecture patterns | +| [Validation Standard](./development/bff/validation.md) | DTO validation + global pipe | +| [DB Mappers](./development/bff/db-mappers.md) | Database mapping | +| [Order Status Updates](./development/bff/order-status-updates.md) | Status update strategy | ### Portal (Frontend) diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index c60c5624..dec75c7f 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -40,9 +40,9 @@ BFF (NestJS) ## Validation Workflow (Zod-First) -- Shared schemas live in `packages/domain/src/validation`. -- Backend registers `nestjs-zod`'s `ZodValidationPipe` globally via `APP_PIPE`. -- Controllers compose schemas by importing contracts from the shared package. For query params and body validation, use `@UsePipes(new ZodValidationPipe(schema))`. +- Shared schemas live in `packages/domain//schema.ts` (and helpers in `packages/domain/toolkit/validation/`). +- BFF registers `nestjs-zod`'s `ZodValidationPipe` globally via `APP_PIPE` (see `apps/bff/src/app.module.ts`). +- Controllers define DTOs via `createZodDto(schema)` and rely on the global pipe for `@Body()`, `@Param()`, and `@Query()` validation (avoid per-route pipes unless you need an override). - Services call `schema.parse` when mapping external data (Salesforce, WHMCS) to ensure the response matches the contract. - Frontend imports the same schemas/types (and `useZodForm` helpers) to keep UI validation in sync with backend rules. - Error handling runs through custom filters; if custom formatting is needed for Zod errors, catch `ZodValidationException`. diff --git a/docs/development/bff/validation.md b/docs/development/bff/validation.md new file mode 100644 index 00000000..754889d3 --- /dev/null +++ b/docs/development/bff/validation.md @@ -0,0 +1,61 @@ +# BFF Validation Standard (2025): DTOs + Global Pipe (Zod) + +This repository follows the “big org standard” for NestJS request validation: + +- **Schemas live in the shared domain layer** (`@customer-portal/domain`) +- **Controllers use DTOs** built from those schemas +- **Validation runs globally** via a single app-wide pipe + +--- + +## Standard Pattern + +### 1) Define the schema in `@customer-portal/domain` + +Put request/param/query schemas in the relevant domain module’s `schema.ts`. + +Example (conceptual): + +```ts +// packages/domain//schema.ts +import { z } from "zod"; + +export const exampleRequestSchema = z.object({ + name: z.string().min(1), +}); +``` + +### 2) Create a DTO in the controller using `createZodDto(schema)` + +```ts +import { createZodDto } from "nestjs-zod"; +import { exampleRequestSchema } from "@customer-portal/domain/"; + +class ExampleRequestDto extends createZodDto(exampleRequestSchema) {} +``` + +Then use `ExampleRequestDto` in `@Body()`, `@Param()`, or `@Query()`. + +### 3) Rely on the global `ZodValidationPipe` + +The BFF registers `ZodValidationPipe` globally via `APP_PIPE` in `apps/bff/src/app.module.ts`. + +That means controllers should **not** import `zod` or define ad-hoc Zod schemas inline for request validation. + +--- + +## Boundary Validation (Integrations / Mapping) + +In addition to request DTO validation, we validate at integration boundaries: + +- **Provider raw → domain**: validate raw payloads and the mapped domain model using domain schemas. +- **BFF → Portal**: use the same domain contracts for stable payload shapes where possible. + +--- + +## Governance / Linting + +We enforce this pattern via ESLint: + +- Controllers are expected to import schemas from `@customer-portal/domain` +- Controllers should not import `zod` directly (to prevent drifting schema definitions into the controller layer) diff --git a/docs/development/domain/packages.md b/docs/development/domain/packages.md index 7870803f..f035a58a 100644 --- a/docs/development/domain/packages.md +++ b/docs/development/domain/packages.md @@ -235,16 +235,16 @@ Ask these questions: ## 📋 Examples with Decisions -| Utility | Decision | Location | Why | -| ---------------------------------- | --------------------- | --------------------------------- | -------------------------- | -| `formatCurrency(amount, currency)` | ✅ Domain | `domain/toolkit/formatting/` | Pure function, no deps | -| `invoiceQueryParamsSchema` | ✅ Domain | `domain/billing/schema.ts` | Domain-specific validation | -| `paginationParamsSchema` | ✅ Domain | `domain/common/schema.ts` | Truly generic | -| `useInvoices()` React hook | ❌ Portal App | `portal/lib/hooks/` | React-specific | -| `apiClient` instance | ❌ Portal App | `portal/lib/api/` | Framework-specific | -| `ZodValidationPipe` | ✅ Validation Package | `packages/validation/` | Reusable bridge | -| `getErrorMessage(error)` | ❌ BFF App | `bff/core/utils/` | App-specific utility | -| `transformWhmcsInvoice()` | ✅ Domain | `domain/billing/providers/whmcs/` | Data transformation | +| Utility | Decision | Location | Why | +| ---------------------------------- | ------------- | --------------------------------- | -------------------------- | +| `formatCurrency(amount, currency)` | ✅ Domain | `domain/toolkit/formatting/` | Pure function, no deps | +| `invoiceQueryParamsSchema` | ✅ Domain | `domain/billing/schema.ts` | Domain-specific validation | +| `paginationParamsSchema` | ✅ Domain | `domain/common/schema.ts` | Truly generic | +| `useInvoices()` React hook | ❌ Portal App | `portal/lib/hooks/` | React-specific | +| `apiClient` instance | ❌ Portal App | `portal/lib/api/` | Framework-specific | +| `ZodValidationPipe` | ❌ BFF App | `apps/bff/src/app.module.ts` | NestJS/framework-specific | +| `getErrorMessage(error)` | ❌ BFF App | `bff/core/utils/` | App-specific utility | +| `transformWhmcsInvoice()` | ✅ Domain | `domain/billing/providers/whmcs/` | Data transformation | --- diff --git a/eslint.config.mjs b/eslint.config.mjs index 83b671f4..775d1b4b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -183,6 +183,23 @@ export default [ }, }, + // ============================================================================= + // BFF controllers: request DTOs must come from shared domain schemas (no inline zod) + // ============================================================================= + { + files: ["apps/bff/src/modules/**/*.controller.ts"], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: "ImportDeclaration[source.value='zod']", + message: + "Do not import zod in controllers. Put request/param/query schemas in @customer-portal/domain and use createZodDto(schema) with the global ZodValidationPipe.", + }, + ], + }, + }, + // ============================================================================= // Node globals for tooling/config files // ============================================================================= diff --git a/packages/domain/billing/index.ts b/packages/domain/billing/index.ts index bb7cd8fb..5a28f90a 100644 --- a/packages/domain/billing/index.ts +++ b/packages/domain/billing/index.ts @@ -25,6 +25,8 @@ export type { BillingSummary, InvoiceQueryParams, InvoiceListQuery, + InvoiceSsoQuery, + InvoicePaymentLinkQuery, } from "./schema.js"; // Provider adapters diff --git a/packages/domain/billing/schema.ts b/packages/domain/billing/schema.ts index a88624ff..b3e5e03c 100644 --- a/packages/domain/billing/schema.ts +++ b/packages/domain/billing/schema.ts @@ -119,6 +119,25 @@ export const invoiceListQuerySchema = z.object({ export type InvoiceListQuery = z.infer; +/** + * Schema for invoice SSO link query parameters + */ +export const invoiceSsoQuerySchema = z.object({ + target: z.enum(["view", "download", "pay"]).optional().default("view"), +}); + +export type InvoiceSsoQuery = z.infer; + +/** + * Schema for invoice payment link query parameters + */ +export const invoicePaymentLinkQuerySchema = z.object({ + paymentMethodId: z.coerce.number().int().positive().optional(), + gatewayName: z.string().optional().default("stripe"), +}); + +export type InvoicePaymentLinkQuery = z.infer; + // ============================================================================ // Inferred Types from Schemas (Schema-First Approach) // ============================================================================ diff --git a/packages/domain/notifications/index.ts b/packages/domain/notifications/index.ts index da20e5e3..610ff795 100644 --- a/packages/domain/notifications/index.ts +++ b/packages/domain/notifications/index.ts @@ -18,9 +18,13 @@ export { notificationSchema, createNotificationRequestSchema, notificationListResponseSchema, + notificationUnreadCountResponseSchema, + notificationQuerySchema, // Types type Notification, type CreateNotificationRequest, type NotificationTemplate, type NotificationListResponse, + type NotificationUnreadCountResponse, + type NotificationQuery, } from "./schema.js"; diff --git a/packages/domain/notifications/schema.ts b/packages/domain/notifications/schema.ts index b2b260e0..04800890 100644 --- a/packages/domain/notifications/schema.ts +++ b/packages/domain/notifications/schema.ts @@ -207,3 +207,20 @@ export const notificationListResponseSchema = z.object({ }); export type NotificationListResponse = z.infer; + +export const notificationUnreadCountResponseSchema = z.object({ + count: z.number(), +}); + +export type NotificationUnreadCountResponse = z.infer; + +/** + * Schema for notification query parameters + */ +export const notificationQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(20), + offset: z.coerce.number().int().nonnegative().optional().default(0), + includeRead: z.coerce.boolean().optional().default(true), +}); + +export type NotificationQuery = z.infer; diff --git a/packages/domain/orders/index.ts b/packages/domain/orders/index.ts index 82602531..0c9c9785 100644 --- a/packages/domain/orders/index.ts +++ b/packages/domain/orders/index.ts @@ -74,6 +74,7 @@ export type { CreateOrderRequest, OrderBusinessValidation, SfOrderIdParam, + OrderListResponse, // Display types OrderDisplayItem, OrderDisplayItemCategory, diff --git a/packages/domain/orders/schema.ts b/packages/domain/orders/schema.ts index 325e86d1..15c27ab2 100644 --- a/packages/domain/orders/schema.ts +++ b/packages/domain/orders/schema.ts @@ -318,6 +318,46 @@ export const checkoutBuildCartRequestSchema = z.object({ export const checkoutBuildCartResponseSchema = apiSuccessResponseSchema(checkoutCartSchema); +// ============================================================================ +// BFF endpoint request/param schemas (DTO inputs) +// ============================================================================ + +/** + * Body for POST /orders/from-checkout-session + */ +export const checkoutSessionCreateOrderRequestSchema = z.object({ + checkoutSessionId: z.string().uuid(), +}); + +/** + * Params for GET /checkout/session/:sessionId + */ +export const checkoutSessionIdParamSchema = z.object({ + sessionId: z.string().uuid(), +}); + +// ============================================================================ +// BFF endpoint response schemas (shared contracts) +// ============================================================================ + +export const checkoutCartSummarySchema = z.object({ + items: z.array(checkoutItemSchema), + totals: checkoutTotalsSchema, +}); + +export const checkoutSessionResponseSchema = apiSuccessResponseSchema( + z.object({ + sessionId: z.string().uuid(), + expiresAt: z.string(), + orderType: z.enum(["Internet", "SIM", "VPN", "Other"]), + cart: checkoutCartSummarySchema, + }) +); + +export const checkoutValidateCartResponseSchema = apiSuccessResponseSchema( + z.object({ valid: z.boolean() }) +); + /** * Schema for order creation response */ @@ -327,6 +367,10 @@ export const orderCreateResponseSchema = z.object({ message: z.string(), }); +export const orderListResponseSchema = z.array(orderSummarySchema); + +export type OrderListResponse = z.infer; + // ============================================================================ // Inferred Types from Schemas (Schema-First Approach) // ============================================================================ @@ -347,6 +391,13 @@ export type CreateOrderRequest = z.infer; export type OrderBusinessValidation = z.infer; export type CheckoutBuildCartRequest = z.infer; export type CheckoutBuildCartResponse = z.infer; +export type CheckoutSessionCreateOrderRequest = z.infer< + typeof checkoutSessionCreateOrderRequestSchema +>; +export type CheckoutSessionIdParam = z.infer; +export type CheckoutCartSummary = z.infer; +export type CheckoutSessionResponse = z.infer; +export type CheckoutValidateCartResponse = z.infer; // ============================================================================ // Order Display Types (for UI presentation) diff --git a/packages/domain/payments/contract.ts b/packages/domain/payments/contract.ts index e9a3dbc3..6c9becc6 100644 --- a/packages/domain/payments/contract.ts +++ b/packages/domain/payments/contract.ts @@ -28,20 +28,6 @@ export const PAYMENT_GATEWAY_TYPE = { MANUAL: "manual", } as const; -// ============================================================================ -// Business Types (Not validated at runtime) -// ============================================================================ - -/** - * Invoice payment link - not validated at runtime - * This is a business domain type used internally - */ -export interface InvoicePaymentLink { - url: string; - expiresAt: string; - gatewayName?: string; -} - // ============================================================================ // Re-export Types from Schema (Schema-First Approach) // ============================================================================ @@ -53,4 +39,5 @@ export type { PaymentGatewayType, PaymentGateway, PaymentGatewayList, + InvoicePaymentLink, } from "./schema.js"; diff --git a/packages/domain/payments/index.ts b/packages/domain/payments/index.ts index 4485a6b5..18ab39a9 100644 --- a/packages/domain/payments/index.ts +++ b/packages/domain/payments/index.ts @@ -7,7 +7,7 @@ */ // Constants -export { PAYMENT_METHOD_TYPE, PAYMENT_GATEWAY_TYPE, type InvoicePaymentLink } from "./contract.js"; +export { PAYMENT_METHOD_TYPE, PAYMENT_GATEWAY_TYPE } from "./contract.js"; // Schemas (includes derived types) export * from "./schema.js"; diff --git a/packages/domain/payments/schema.ts b/packages/domain/payments/schema.ts index a9af7daf..a6623791 100644 --- a/packages/domain/payments/schema.ts +++ b/packages/domain/payments/schema.ts @@ -55,6 +55,12 @@ export const paymentGatewayListSchema = z.object({ totalCount: z.number().int().min(0), }); +export const invoicePaymentLinkSchema = z.object({ + url: z.string().url(), + expiresAt: z.string(), + gatewayName: z.string().optional(), +}); + // ============================================================================ // Inferred Types from Schemas (Schema-First Approach) // ============================================================================ @@ -65,3 +71,4 @@ export type PaymentMethodList = z.infer; export type PaymentGatewayType = z.infer; export type PaymentGateway = z.infer; export type PaymentGatewayList = z.infer; +export type InvoicePaymentLink = z.infer; diff --git a/packages/domain/services/schema.ts b/packages/domain/services/schema.ts index 33dae875..b65b2f71 100644 --- a/packages/domain/services/schema.ts +++ b/packages/domain/services/schema.ts @@ -5,6 +5,7 @@ */ import { z } from "zod"; +import { addressSchema } from "../customer/index.js"; // ============================================================================ // Base Catalog Product Schema @@ -111,6 +112,15 @@ export const internetEligibilityDetailsSchema = z.object({ notes: z.string().nullable(), }); +/** + * Body for POST /services/internet/eligibility-request (BFF). + * Lives in domain so Portal + BFF stay aligned. + */ +export const internetEligibilityRequestSchema = z.object({ + notes: z.string().trim().max(2000).optional(), + address: addressSchema.partial().optional(), +}); + // ============================================================================ // SIM Product Schemas // ============================================================================ @@ -184,6 +194,7 @@ export type InternetAddonCatalogItem = z.infer; export type InternetEligibilityStatus = z.infer; export type InternetEligibilityDetails = z.infer; +export type InternetEligibilityRequest = z.infer; // SIM products export type SimCatalogProduct = z.infer; diff --git a/packages/domain/sim/index.ts b/packages/domain/sim/index.ts index dd52c154..0cde1bb1 100644 --- a/packages/domain/sim/index.ts +++ b/packages/domain/sim/index.ts @@ -48,6 +48,10 @@ export type { SimInternationalCallHistoryResponse, SimSmsRecord, SimSmsHistoryResponse, + SimHistoryMonth, + SimHistoryAvailableMonths, + SimCallHistoryImportResult, + SimSftpFiles, // Request types SimTopUpRequest, SimPlanChangeRequest, @@ -63,6 +67,10 @@ export type { SimCancelFullRequest, SimTopUpFullRequest, SimChangePlanFullRequest, + SimHistoryQuery, + SimSftpListQuery, + SimCallHistoryImportQuery, + SimReissueEsimRequest, // Activation types SimOrderActivationRequest, SimOrderActivationMnp, diff --git a/packages/domain/sim/schema.ts b/packages/domain/sim/schema.ts index 7be63574..de326461 100644 --- a/packages/domain/sim/schema.ts +++ b/packages/domain/sim/schema.ts @@ -234,7 +234,24 @@ export type SimReissueFullRequest = z.infer; // SIM Call/SMS History (portal-facing) // ============================================================================ -const simHistoryMonthSchema = z.string().regex(/^\d{4}-\d{2}$/, "Month must be in YYYY-MM format"); +export const simHistoryMonthSchema = z + .string() + .regex(/^\d{4}-\d{2}$/, "Month must be in YYYY-MM format"); +export type SimHistoryMonth = z.infer; + +export const simHistoryAvailableMonthsSchema = z.array(simHistoryMonthSchema); +export type SimHistoryAvailableMonths = z.infer; + +export const simCallHistoryImportResultSchema = z.object({ + domestic: z.number().int().min(0), + international: z.number().int().min(0), + sms: z.number().int().min(0), +}); +export type SimCallHistoryImportResult = z.infer; + +export const simSftpFilesSchema = z.array(z.string()); +export type SimSftpFiles = z.infer; + const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"); const timeHmsSchema = z.string().regex(/^\d{2}:\d{2}:\d{2}$/, "Time must be in HH:MM:SS format"); @@ -288,6 +305,47 @@ export type SimInternationalCallHistoryResponse = z.infer< typeof simInternationalCallHistoryResponseSchema >; +/** + * Schema for SIM history query parameters (pagination + month) + */ +export const simHistoryQuerySchema = z.object({ + month: simHistoryMonthSchema.optional(), + page: z.coerce.number().int().min(1).optional().default(1), + limit: z.coerce.number().int().min(1).max(100).optional().default(50), +}); + +export type SimHistoryQuery = z.infer; + +/** + * Schema for SFTP file listing query + */ +export const simSftpListQuerySchema = z.object({ + path: z.string().startsWith("/home/PASI", "Invalid path").default("/home/PASI"), +}); + +export type SimSftpListQuery = z.infer; + +/** + * Schema for call history import query + */ +export const simCallHistoryImportQuerySchema = z.object({ + month: z.string().regex(/^\d{6}$/, "Invalid month format (expected YYYYMM)"), +}); + +export type SimCallHistoryImportQuery = z.infer; + +/** + * Schema for SIM eSIM reissue request + */ +export const simReissueEsimRequestSchema = z.object({ + newEid: z + .string() + .regex(/^\d{32}$/, "EID must be exactly 32 digits") + .optional(), +}); + +export type SimReissueEsimRequest = z.infer; + export const simSmsRecordSchema = z.object({ id: z.string(), date: isoDateSchema,