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.
This commit is contained in:
barsa 2025-12-26 13:04:15 +09:00
parent 851207b401
commit 10c8461661
37 changed files with 682 additions and 981 deletions

View File

@ -1,9 +1,10 @@
import { Module } from "@nestjs/common"; 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 { RouterModule } from "@nestjs/core";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule"; 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 // Configuration
import { appConfig } from "@bff/core/config/app.config.js"; 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, provide: APP_PIPE,
useClass: ZodValidationPipe, useClass: ZodValidationPipe,
}, },
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: ZodSerializerInterceptor,
},
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,7 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { SecureErrorMapperService } from "./services/secure-error-mapper.service.js";
import { CsrfService } from "./services/csrf.service.js"; import { CsrfService } from "./services/csrf.service.js";
import { CsrfMiddleware } from "./middleware/csrf.middleware.js"; import { CsrfMiddleware } from "./middleware/csrf.middleware.js";
import { CsrfController } from "./controllers/csrf.controller.js"; import { CsrfController } from "./controllers/csrf.controller.js";
@ -10,8 +9,8 @@ import { AdminGuard } from "./guards/admin.guard.js";
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],
controllers: [CsrfController], controllers: [CsrfController],
providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware, AdminGuard], providers: [CsrfService, CsrfMiddleware, AdminGuard],
exports: [SecureErrorMapperService, CsrfService, AdminGuard], exports: [CsrfService, AdminGuard],
}) })
export class SecurityModule implements NestModule { export class SecurityModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {

View File

@ -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<string, SecureErrorMapping>;
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<string, unknown>
): 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<string, SecureErrorMapping> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>
): 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,
});
}
}

View File

@ -7,7 +7,6 @@ import {
Get, Get,
Req, Req,
HttpCode, HttpCode,
UsePipes,
Res, Res,
} from "@nestjs/common"; } from "@nestjs/common";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
@ -20,7 +19,7 @@ import {
} from "./guards/failed-login-throttle.guard.js"; } from "./guards/failed-login-throttle.guard.js";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
import { Public } from "../../decorators/public.decorator.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 type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.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"; import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
@ -43,17 +42,6 @@ import {
refreshTokenRequestSchema, refreshTokenRequestSchema,
checkPasswordNeededResponseSchema, checkPasswordNeededResponseSchema,
linkWhmcsResponseSchema, 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"; } from "@customer-portal/domain/auth";
type CookieValue = string | undefined; type CookieValue = string | undefined;
@ -61,6 +49,20 @@ type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, CookieValue>; cookies?: Record<string, CookieValue>;
}; };
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 calculateCookieMaxAge = (isoTimestamp: string): number => {
const expiresAt = Date.parse(isoTimestamp); const expiresAt = Date.parse(isoTimestamp);
if (Number.isNaN(expiresAt)) { if (Number.isNaN(expiresAt)) {
@ -128,8 +130,7 @@ export class AuthController {
@Post("validate-signup") @Post("validate-signup")
@UseGuards(RateLimitGuard, SalesforceReadThrottleGuard) @UseGuards(RateLimitGuard, SalesforceReadThrottleGuard)
@RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP @RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP
@UsePipes(new ZodValidationPipe(validateSignupRequestSchema)) async validateSignup(@Body() validateData: ValidateSignupRequestDto, @Req() req: Request) {
async validateSignup(@Body() validateData: ValidateSignupRequest, @Req() req: Request) {
return this.authFacade.validateSignup(validateData, req); return this.authFacade.validateSignup(validateData, req);
} }
@ -143,16 +144,14 @@ export class AuthController {
@Post("signup-preflight") @Post("signup-preflight")
@UseGuards(RateLimitGuard, SalesforceReadThrottleGuard) @UseGuards(RateLimitGuard, SalesforceReadThrottleGuard)
@RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP @RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP
@UsePipes(new ZodValidationPipe(signupRequestSchema))
@HttpCode(200) @HttpCode(200)
async signupPreflight(@Body() signupData: SignupRequest) { async signupPreflight(@Body() signupData: SignupRequestDto) {
return this.authFacade.signupPreflight(signupData); return this.authFacade.signupPreflight(signupData);
} }
@Public() @Public()
@Post("account-status") @Post("account-status")
@UsePipes(new ZodValidationPipe(accountStatusRequestSchema)) async accountStatus(@Body() body: AccountStatusRequestDto) {
async accountStatus(@Body() body: AccountStatusRequest) {
return this.authFacade.getAccountStatus(body.email); return this.authFacade.getAccountStatus(body.email);
} }
@ -160,9 +159,8 @@ export class AuthController {
@Post("signup") @Post("signup")
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 signups per 15 minutes per IP (reasonable for account creation) @RateLimit({ limit: 5, ttl: 900 }) // 5 signups per 15 minutes per IP (reasonable for account creation)
@UsePipes(new ZodValidationPipe(signupRequestSchema))
async signup( async signup(
@Body() signupData: SignupRequest, @Body() signupData: SignupRequestDto,
@Req() req: Request, @Req() req: Request,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
) { ) {
@ -212,9 +210,8 @@ export class AuthController {
@Post("refresh") @Post("refresh")
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
@RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP @RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
@UsePipes(new ZodValidationPipe(refreshTokenRequestSchema))
async refreshToken( async refreshToken(
@Body() body: RefreshTokenRequest, @Body() body: RefreshTokenRequestDto,
@Req() req: RequestWithCookies, @Req() req: RequestWithCookies,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
) { ) {
@ -233,19 +230,18 @@ export class AuthController {
@Post("migrate") @Post("migrate")
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) @ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto })
async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { async migrateAccount(@Body() linkData: LinkWhmcsRequestDto, @Req() _req: Request) {
const result = await this.authFacade.linkWhmcsUser(linkData); const result = await this.authFacade.linkWhmcsUser(linkData);
return linkWhmcsResponseSchema.parse(result); return result;
} }
@Public() @Public()
@Post("set-password") @Post("set-password")
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard)
@UsePipes(new ZodValidationPipe(setPasswordRequestSchema))
async setPassword( async setPassword(
@Body() setPasswordData: SetPasswordRequest, @Body() setPasswordData: SetPasswordRequestDto,
@Req() _req: Request, @Req() _req: Request,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
) { ) {
@ -256,19 +252,22 @@ export class AuthController {
@Public() @Public()
@Post("check-password-needed") @Post("check-password-needed")
@UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema))
@HttpCode(200) @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); const response = await this.authFacade.checkPasswordNeeded(data.email);
return checkPasswordNeededResponseSchema.parse(response); return response;
} }
@Public() @Public()
@Post("request-password-reset") @Post("request-password-reset")
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
@UsePipes(new ZodValidationPipe(passwordResetRequestSchema)) async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) {
async requestPasswordReset(@Body() body: PasswordResetRequest, @Req() req: Request) {
await this.authFacade.requestPasswordReset(body.email, req); await this.authFacade.requestPasswordReset(body.email, req);
return { message: "If an account exists, a reset email has been sent" }; return { message: "If an account exists, a reset email has been sent" };
} }
@ -278,9 +277,8 @@ export class AuthController {
@HttpCode(200) @HttpCode(200)
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
@UsePipes(new ZodValidationPipe(passwordResetSchema))
async resetPassword( async resetPassword(
@Body() body: ResetPasswordRequest, @Body() body: ResetPasswordRequestDto,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
) { ) {
await this.authFacade.resetPassword(body.token, body.password); await this.authFacade.resetPassword(body.token, body.password);
@ -293,10 +291,9 @@ export class AuthController {
@Post("change-password") @Post("change-password")
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes @RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes
@UsePipes(new ZodValidationPipe(changePasswordRequestSchema))
async changePassword( async changePassword(
@Req() req: Request & { user: { id: string } }, @Req() req: Request & { user: { id: string } },
@Body() body: ChangePasswordRequest, @Body() body: ChangePasswordRequestDto,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
) { ) {
const result = await this.authFacade.changePassword(req.user.id, body, req); const result = await this.authFacade.changePassword(req.user.id, body, req);
@ -310,10 +307,9 @@ export class AuthController {
} }
@Post("sso-link") @Post("sso-link")
@UsePipes(new ZodValidationPipe(ssoLinkRequestSchema))
async createSsoLink( async createSsoLink(
@Req() req: Request & { user: { id: string } }, @Req() req: Request & { user: { id: string } },
@Body() body: SsoLinkRequest @Body() body: SsoLinkRequestDto
) { ) {
const destination = body?.destination; const destination = body?.destination;
return this.authFacade.createSsoLink(req.user.id, destination); return this.authFacade.createSsoLink(req.user.id, destination);

View File

@ -8,32 +8,48 @@ import {
ParseIntPipe, ParseIntPipe,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
BadRequestException,
} from "@nestjs/common"; } from "@nestjs/common";
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service.js"; import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.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 { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import type { import type { Invoice, InvoiceList, InvoiceSsoLink } from "@customer-portal/domain/billing";
Invoice, import {
InvoiceList, invoiceListQuerySchema,
InvoiceSsoLink, invoiceListSchema,
InvoiceListQuery, invoiceSchema,
invoiceSsoLinkSchema,
invoiceSsoQuerySchema,
invoicePaymentLinkQuerySchema,
} from "@customer-portal/domain/billing"; } from "@customer-portal/domain/billing";
import { invoiceListQuerySchema, invoiceSchema } from "@customer-portal/domain/billing";
import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Subscription } from "@customer-portal/domain/subscriptions";
import type { import type {
PaymentMethodList, PaymentMethodList,
PaymentGatewayList, PaymentGatewayList,
InvoicePaymentLink, InvoicePaymentLink,
} from "@customer-portal/domain/payments"; } 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 * 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. * Business logic is delegated to service layer.
*/ */
@Controller("invoices") @Controller("invoices")
@ -45,14 +61,16 @@ export class InvoicesController {
) {} ) {}
@Get() @Get()
@ZodResponse({ description: "List invoices", type: InvoiceListDto })
async getInvoices( async getInvoices(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Query(new ZodValidationPipe(invoiceListQuerySchema)) query: InvoiceListQuery @Query() query: InvoiceListQueryDto
): Promise<InvoiceList> { ): Promise<InvoiceList> {
return this.invoicesService.getInvoices(req.user.id, query); return this.invoicesService.getInvoices(req.user.id, query);
} }
@Get("payment-methods") @Get("payment-methods")
@ZodResponse({ description: "List payment methods", type: PaymentMethodListDto })
async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> { async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
const mapping = await this.mappingsService.findByUserId(req.user.id); const mapping = await this.mappingsService.findByUserId(req.user.id);
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {
@ -62,12 +80,14 @@ export class InvoicesController {
} }
@Get("payment-gateways") @Get("payment-gateways")
@ZodResponse({ description: "List payment gateways", type: PaymentGatewayListDto })
async getPaymentGateways(): Promise<PaymentGatewayList> { async getPaymentGateways(): Promise<PaymentGatewayList> {
return this.whmcsService.getPaymentGateways(); return this.whmcsService.getPaymentGateways();
} }
@Post("payment-methods/refresh") @Post("payment-methods/refresh")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ZodResponse({ description: "Refresh payment methods", type: PaymentMethodListDto })
async refreshPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> { async refreshPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
// Invalidate cache first // Invalidate cache first
await this.whmcsService.invalidatePaymentMethodsCache(req.user.id); await this.whmcsService.invalidatePaymentMethodsCache(req.user.id);
@ -81,23 +101,19 @@ export class InvoicesController {
} }
@Get(":id") @Get(":id")
@ZodResponse({ description: "Get invoice by id", type: InvoiceDto })
async getInvoiceById( async getInvoiceById(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) invoiceId: number @Param("id", ParseIntPipe) invoiceId: number
): Promise<Invoice> { ): Promise<Invoice> {
// Validate using domain schema
invoiceSchema.shape.id.parse(invoiceId);
return this.invoicesService.getInvoiceById(req.user.id, invoiceId); return this.invoicesService.getInvoiceById(req.user.id, invoiceId);
} }
@Get(":id/subscriptions") @Get(":id/subscriptions")
getInvoiceSubscriptions( getInvoiceSubscriptions(
@Request() _req: RequestWithUser, @Request() _req: RequestWithUser,
@Param("id", ParseIntPipe) invoiceId: number @Param("id", ParseIntPipe) _invoiceId: number
): Subscription[] { ): Subscription[] {
// Validate using domain schema
invoiceSchema.shape.id.parse(invoiceId);
// This functionality has been moved to WHMCS directly // This functionality has been moved to WHMCS directly
// For now, return empty array as subscriptions are managed in WHMCS // For now, return empty array as subscriptions are managed in WHMCS
return []; return [];
@ -105,28 +121,23 @@ export class InvoicesController {
@Post(":id/sso-link") @Post(":id/sso-link")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ZodResponse({ description: "Create invoice SSO link", type: InvoiceSsoLinkDto })
async createSsoLink( async createSsoLink(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) invoiceId: number, @Param("id", ParseIntPipe) invoiceId: number,
@Query("target") target?: "view" | "download" | "pay" @Query() query: InvoiceSsoQueryDto
): Promise<InvoiceSsoLink> { ): Promise<InvoiceSsoLink> {
// 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); const mapping = await this.mappingsService.findByUserId(req.user.id);
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {
throw new Error("WHMCS client mapping not found"); throw new Error("WHMCS client mapping not found");
} }
const parsedQuery = invoiceSsoQuerySchema.parse(query as unknown);
const ssoUrl = await this.whmcsService.whmcsSsoForInvoice( const ssoUrl = await this.whmcsService.whmcsSsoForInvoice(
mapping.whmcsClientId, mapping.whmcsClientId,
invoiceId, invoiceId,
target || "view" parsedQuery.target
); );
return { return {
@ -137,36 +148,30 @@ export class InvoicesController {
@Post(":id/payment-link") @Post(":id/payment-link")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ZodResponse({ description: "Create invoice payment link", type: InvoicePaymentLinkDto })
async createPaymentLink( async createPaymentLink(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) invoiceId: number, @Param("id", ParseIntPipe) invoiceId: number,
@Query("paymentMethodId") paymentMethodId?: string, @Query() query: InvoicePaymentLinkQueryDto
@Query("gatewayName") gatewayName?: string
): Promise<InvoicePaymentLink> { ): Promise<InvoicePaymentLink> {
// 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); const mapping = await this.mappingsService.findByUserId(req.user.id);
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {
throw new Error("WHMCS client mapping not found"); throw new Error("WHMCS client mapping not found");
} }
const parsedQuery = invoicePaymentLinkQuerySchema.parse(query as unknown);
const ssoResult = await this.whmcsService.createPaymentSsoToken( const ssoResult = await this.whmcsService.createPaymentSsoToken(
mapping.whmcsClientId, mapping.whmcsClientId,
invoiceId, invoiceId,
paymentMethodIdNum, parsedQuery.paymentMethodId,
gatewayName || "stripe" parsedQuery.gatewayName
); );
return { return {
url: ssoResult.url, url: ssoResult.url,
expiresAt: ssoResult.expiresAt, expiresAt: ssoResult.expiresAt,
gatewayName: gatewayName || "stripe", gatewayName: parsedQuery.gatewayName,
}; };
} }
} }

View File

@ -10,7 +10,7 @@ import { InvoiceHealthService } from "./services/invoice-health.service.js";
/** /**
* Invoice Module * 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. * No separate validator service needed.
*/ */
@Module({ @Module({

View File

@ -4,23 +4,28 @@
* API endpoints for managing in-app notifications. * API endpoints for managing in-app notifications.
*/ */
import { import { Controller, Get, Post, Param, Query, Req, UseGuards } from "@nestjs/common";
Controller,
Get,
Post,
Param,
Query,
Req,
UseGuards,
ParseIntPipe,
DefaultValuePipe,
ParseBoolPipe,
} from "@nestjs/common";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { NotificationService } from "./notifications.service.js"; import { NotificationService } from "./notifications.service.js";
import type { NotificationListResponse } from "@customer-portal/domain/notifications"; import {
import type { ApiSuccessAckResponse } from "@customer-portal/domain/common"; 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") @Controller("notifications")
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
@ -32,17 +37,17 @@ export class NotificationsController {
*/ */
@Get() @Get()
@RateLimit({ limit: 60, ttl: 60 }) @RateLimit({ limit: 60, ttl: 60 })
@ZodResponse({ description: "Get notifications", type: NotificationListResponseDto })
async getNotifications( async getNotifications(
@Req() req: RequestWithUser, @Req() req: RequestWithUser,
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number, @Query() query: NotificationQueryDto
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("includeRead", new DefaultValuePipe(true), ParseBoolPipe)
includeRead: boolean
): Promise<NotificationListResponse> { ): Promise<NotificationListResponse> {
const parsedQuery = notificationQuerySchema.parse(query as unknown);
return this.notificationService.getNotifications(req.user.id, { return this.notificationService.getNotifications(req.user.id, {
limit: Math.min(limit, 50), // Cap at 50 limit: Math.min(parsedQuery.limit, 50), // Cap at 50
offset, offset: parsedQuery.offset,
includeRead, includeRead: parsedQuery.includeRead,
}); });
} }
@ -51,6 +56,7 @@ export class NotificationsController {
*/ */
@Get("unread-count") @Get("unread-count")
@RateLimit({ limit: 120, ttl: 60 }) @RateLimit({ limit: 120, ttl: 60 })
@ZodResponse({ description: "Get unread count", type: NotificationUnreadCountResponseDto })
async getUnreadCount(@Req() req: RequestWithUser): Promise<{ count: number }> { async getUnreadCount(@Req() req: RequestWithUser): Promise<{ count: number }> {
const count = await this.notificationService.getUnreadCount(req.user.id); const count = await this.notificationService.getUnreadCount(req.user.id);
return { count }; return { count };
@ -61,6 +67,7 @@ export class NotificationsController {
*/ */
@Post(":id/read") @Post(":id/read")
@RateLimit({ limit: 60, ttl: 60 }) @RateLimit({ limit: 60, ttl: 60 })
@ZodResponse({ description: "Mark as read", type: ApiSuccessAckResponseDto })
async markAsRead( async markAsRead(
@Req() req: RequestWithUser, @Req() req: RequestWithUser,
@Param("id") notificationId: string @Param("id") notificationId: string
@ -74,6 +81,7 @@ export class NotificationsController {
*/ */
@Post("read-all") @Post("read-all")
@RateLimit({ limit: 10, ttl: 60 }) @RateLimit({ limit: 10, ttl: 60 })
@ZodResponse({ description: "Mark all as read", type: ApiSuccessAckResponseDto })
async markAllAsRead(@Req() req: RequestWithUser): Promise<ApiSuccessAckResponse> { async markAllAsRead(@Req() req: RequestWithUser): Promise<ApiSuccessAckResponse> {
await this.notificationService.markAllAsRead(req.user.id); await this.notificationService.markAllAsRead(req.user.id);
return { success: true }; return { success: true };
@ -84,6 +92,7 @@ export class NotificationsController {
*/ */
@Post(":id/dismiss") @Post(":id/dismiss")
@RateLimit({ limit: 60, ttl: 60 }) @RateLimit({ limit: 60, ttl: 60 })
@ZodResponse({ description: "Dismiss notification", type: ApiSuccessAckResponseDto })
async dismiss( async dismiss(
@Req() req: RequestWithUser, @Req() req: RequestWithUser,
@Param("id") notificationId: string @Param("id") notificationId: string

View File

@ -1,48 +1,26 @@
import { import { Body, Controller, Get, Param, Post, Request, UseGuards, Inject } from "@nestjs/common";
Body,
Controller,
Get,
Param,
Post,
Request,
UseGuards,
UsePipes,
Inject,
} from "@nestjs/common";
import { Logger } from "nestjs-pino"; 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 { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { CheckoutService } from "../services/checkout.service.js"; import { CheckoutService } from "../services/checkout.service.js";
import { CheckoutSessionService } from "../services/checkout-session.service.js"; import { CheckoutSessionService } from "../services/checkout-session.service.js";
import { import {
checkoutItemSchema,
checkoutCartSchema, checkoutCartSchema,
checkoutBuildCartRequestSchema, checkoutBuildCartRequestSchema,
checkoutBuildCartResponseSchema, checkoutBuildCartResponseSchema,
checkoutTotalsSchema, checkoutSessionIdParamSchema,
checkoutSessionResponseSchema,
checkoutValidateCartResponseSchema,
} from "@customer-portal/domain/orders"; } 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 type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() })); class CheckoutBuildCartRequestDto extends createZodDto(checkoutBuildCartRequestSchema) {}
const checkoutSessionIdParamSchema = z.object({ sessionId: z.string().uuid() }); class CheckoutSessionIdParamDto extends createZodDto(checkoutSessionIdParamSchema) {}
class CheckoutCartDto extends createZodDto(checkoutCartSchema) {}
const checkoutCartSummarySchema = z.object({ class CheckoutBuildCartResponseDto extends createZodDto(checkoutBuildCartResponseSchema) {}
items: z.array(checkoutItemSchema), class CheckoutSessionResponseDto extends createZodDto(checkoutSessionResponseSchema) {}
totals: checkoutTotalsSchema, class ValidateCartResponseDto extends createZodDto(checkoutValidateCartResponseSchema) {}
});
const checkoutSessionResponseSchema = apiSuccessResponseSchema(
z.object({
sessionId: z.string().uuid(),
expiresAt: z.string(),
orderType: z.enum(["Internet", "SIM", "VPN", "Other"]),
cart: checkoutCartSummarySchema,
})
);
@Controller("checkout") @Controller("checkout")
@Public() // Cart building and validation can be done without authentication @Public() // Cart building and validation can be done without authentication
@ -55,8 +33,12 @@ export class CheckoutController {
@Post("cart") @Post("cart")
@UseGuards(SalesforceReadThrottleGuard) @UseGuards(SalesforceReadThrottleGuard)
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema)) @ZodResponse({
async buildCart(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) { status: 201,
description: "Build checkout cart",
type: CheckoutBuildCartResponseDto,
})
async buildCart(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequestDto) {
this.logger.log("Building checkout cart", { this.logger.log("Building checkout cart", {
userId: req.user?.id, userId: req.user?.id,
orderType: body.orderType, orderType: body.orderType,
@ -70,10 +52,7 @@ export class CheckoutController {
req.user?.id req.user?.id
); );
return checkoutBuildCartResponseSchema.parse({ return { success: true as const, data: cart };
success: true,
data: cart,
});
} catch (error) { } catch (error) {
this.logger.error("Failed to build checkout cart", { this.logger.error("Failed to build checkout cart", {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
@ -90,8 +69,12 @@ export class CheckoutController {
*/ */
@Post("session") @Post("session")
@UseGuards(SalesforceReadThrottleGuard) @UseGuards(SalesforceReadThrottleGuard)
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema)) @ZodResponse({
async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) { status: 201,
description: "Create checkout session",
type: CheckoutSessionResponseDto,
})
async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequestDto) {
this.logger.log("Creating checkout session", { this.logger.log("Creating checkout session", {
userId: req.user?.id, userId: req.user?.id,
orderType: body.orderType, orderType: body.orderType,
@ -106,8 +89,8 @@ export class CheckoutController {
const session = await this.checkoutSessions.createSession(body, cart); const session = await this.checkoutSessions.createSession(body, cart);
return checkoutSessionResponseSchema.parse({ return {
success: true, success: true as const,
data: { data: {
sessionId: session.sessionId, sessionId: session.sessionId,
expiresAt: session.expiresAt, expiresAt: session.expiresAt,
@ -117,16 +100,20 @@ export class CheckoutController {
totals: cart.totals, totals: cart.totals,
}, },
}, },
}); };
} }
@Get("session/:sessionId") @Get("session/:sessionId")
@UseGuards(SalesforceReadThrottleGuard) @UseGuards(SalesforceReadThrottleGuard)
@UsePipes(new ZodValidationPipe(checkoutSessionIdParamSchema)) @ZodResponse({
async getSession(@Param() params: { sessionId: string }) { status: 200,
description: "Get checkout session",
type: CheckoutSessionResponseDto,
})
async getSession(@Param() params: CheckoutSessionIdParamDto) {
const session = await this.checkoutSessions.getSession(params.sessionId); const session = await this.checkoutSessions.getSession(params.sessionId);
return checkoutSessionResponseSchema.parse({ return {
success: true, success: true as const,
data: { data: {
sessionId: params.sessionId, sessionId: params.sessionId,
expiresAt: session.expiresAt, expiresAt: session.expiresAt,
@ -136,12 +123,16 @@ export class CheckoutController {
totals: session.cart.totals, totals: session.cart.totals,
}, },
}, },
}); };
} }
@Post("validate") @Post("validate")
@UsePipes(new ZodValidationPipe(checkoutCartSchema)) @ZodResponse({
validateCart(@Body() cart: CheckoutCart) { status: 201,
description: "Validate checkout cart",
type: ValidateCartResponseDto,
})
validateCart(@Body() cart: CheckoutCartDto) {
this.logger.log("Validating checkout cart", { this.logger.log("Validating checkout cart", {
itemCount: cart.items.length, itemCount: cart.items.length,
}); });
@ -149,10 +140,7 @@ export class CheckoutController {
try { try {
this.checkoutService.validateCart(cart); this.checkoutService.validateCart(cart);
return validateCartResponseSchema.parse({ return { success: true as const, data: { valid: true } };
success: true,
data: { valid: true },
});
} catch (error) { } catch (error) {
this.logger.error("Checkout cart validation failed", { this.logger.error("Checkout cart validation failed", {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),

View File

@ -7,7 +7,6 @@ import {
Post, Post,
Request, Request,
Sse, Sse,
UsePipes,
UseGuards, UseGuards,
UnauthorizedException, UnauthorizedException,
type MessageEvent, 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 { OrderOrchestrator } from "./services/order-orchestrator.service.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "nestjs-zod"; import { createZodDto, ZodResponse } from "nestjs-zod";
import { import {
checkoutSessionCreateOrderRequestSchema,
createOrderRequestSchema, createOrderRequestSchema,
orderCreateResponseSchema, orderCreateResponseSchema,
sfOrderIdParamSchema, sfOrderIdParamSchema,
orderDetailsSchema,
orderListResponseSchema,
type CreateOrderRequest, type CreateOrderRequest,
type SfOrderIdParam,
} from "@customer-portal/domain/orders"; } from "@customer-portal/domain/orders";
import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
import { Observable } from "rxjs"; 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 { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
import { CheckoutService } from "./services/checkout.service.js"; import { CheckoutService } from "./services/checkout.service.js";
import { CheckoutSessionService } from "./services/checkout-session.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({ class CreateOrderRequestDto extends createZodDto(createOrderRequestSchema) {}
checkoutSessionId: z.string().uuid(), 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") @Controller("orders")
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
@ -48,13 +54,11 @@ export class OrdersController {
private readonly logger: Logger private readonly logger: Logger
) {} ) {}
private readonly createOrderResponseSchema = apiSuccessResponseSchema(orderCreateResponseSchema);
@Post() @Post()
@UseGuards(SalesforceWriteThrottleGuard) @UseGuards(SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute @RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute
@UsePipes(new ZodValidationPipe(createOrderRequestSchema)) @ZodResponse({ status: 201, description: "Create order", type: CreateOrderResponseDto })
async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) { async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequestDto) {
this.logger.log( this.logger.log(
{ {
userId: req.user?.id, userId: req.user?.id,
@ -66,7 +70,7 @@ export class OrdersController {
try { try {
const result = await this.orderOrchestrator.createOrder(req.user.id, body); 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) { } catch (error) {
this.logger.error( this.logger.error(
{ {
@ -83,10 +87,14 @@ export class OrdersController {
@Post("from-checkout-session") @Post("from-checkout-session")
@UseGuards(SalesforceWriteThrottleGuard) @UseGuards(SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute @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( async createFromCheckoutSession(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Body() body: { checkoutSessionId: string } @Body() body: CheckoutSessionCreateOrderDto
) { ) {
this.logger.log( this.logger.log(
{ {
@ -129,19 +137,20 @@ export class OrdersController {
await this.checkoutSessions.deleteSession(body.checkoutSessionId); await this.checkoutSessions.deleteSession(body.checkoutSessionId);
return this.createOrderResponseSchema.parse({ success: true, data: result }); return { success: true as const, data: result };
} }
@Get("user") @Get("user")
@UseGuards(SalesforceReadThrottleGuard) @UseGuards(SalesforceReadThrottleGuard)
@ZodResponse({ description: "Get user orders", type: OrderListResponseDto })
async getUserOrders(@Request() req: RequestWithUser) { async getUserOrders(@Request() req: RequestWithUser) {
return this.orderOrchestrator.getOrdersForUser(req.user.id); return this.orderOrchestrator.getOrdersForUser(req.user.id);
} }
@Get(":sfOrderId") @Get(":sfOrderId")
@UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
@UseGuards(SalesforceReadThrottleGuard) @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) { if (!req.user?.id) {
throw new UnauthorizedException("Authentication required"); throw new UnauthorizedException("Authentication required");
} }
@ -149,11 +158,11 @@ export class OrdersController {
} }
@Sse(":sfOrderId/events") @Sse(":sfOrderId/events")
@UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
@UseGuards(SalesforceReadThrottleGuard) @UseGuards(SalesforceReadThrottleGuard)
@SkipSuccessEnvelope()
async streamOrderUpdates( async streamOrderUpdates(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param() params: SfOrderIdParam @Param() params: SfOrderIdParamDto
): Promise<Observable<MessageEvent>> { ): Promise<Observable<MessageEvent>> {
// Ensure caller is allowed to access this order stream (avoid leaking existence) // Ensure caller is allowed to access this order stream (avoid leaking existence)
try { try {

View File

@ -9,6 +9,7 @@ import { OrderItemBuilder } from "./order-item-builder.service.js";
import type { OrderItemCompositePayload } from "./order-item-builder.service.js"; import type { OrderItemCompositePayload } from "./order-item-builder.service.js";
import { OrdersCacheService } from "./orders-cache.service.js"; import { OrdersCacheService } from "./orders-cache.service.js";
import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders"; 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 { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
@ -35,12 +36,12 @@ export class OrderOrchestrator {
/** /**
* Create a new order - main entry point * 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"); this.logger.log({ userId }, "Order creation workflow started");
// 1) Complete validation (format + business rules) // 1) Complete validation (format + business rules)
const { validatedBody, userMapping, pricebookId } = const { validatedBody, userMapping, pricebookId } =
await this.orderValidator.validateCompleteOrder(userId, rawBody); await this.orderValidator.validateCompleteOrder(userId, body);
this.logger.log( this.logger.log(
{ {

View File

@ -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 { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { import {
createOrderRequestSchema,
orderWithSkuValidationSchema, orderWithSkuValidationSchema,
type CreateOrderRequest, type CreateOrderRequest,
type OrderBusinessValidation, type OrderBusinessValidation,
@ -38,48 +37,6 @@ export class OrderValidator {
private readonly residenceCards: ResidenceCardService 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 * Validate user mapping exists - simple business logic
*/ */
@ -222,7 +179,7 @@ export class OrderValidator {
*/ */
async validateCompleteOrder( async validateCompleteOrder(
userId: string, userId: string,
rawBody: unknown body: CreateOrderRequest
): Promise<{ ): Promise<{
validatedBody: OrderBusinessValidation; validatedBody: OrderBusinessValidation;
userMapping: { userId: string; sfAccountId?: string; whmcsClientId: number }; userMapping: { userId: string; sfAccountId?: string; whmcsClientId: number };
@ -230,8 +187,8 @@ export class OrderValidator {
}> { }> {
this.logger.log({ userId }, "Starting complete order validation"); this.logger.log({ userId }, "Starting complete order validation");
// 1. Format validation (replaces DTO validation) // 1. Format validation is performed in the controller layer via Zod DTO + global pipe.
const validatedBody = this.validateRequestFormat(rawBody); const validatedBody = body;
// 1b. Business validation (ensures userId-specific constraints) // 1b. Business validation (ensures userId-specific constraints)
let businessValidatedBody: OrderBusinessValidation; let businessValidatedBody: OrderBusinessValidation;

View File

@ -18,6 +18,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { RealtimeConnectionLimiterService } from "./realtime-connection-limiter.service.js"; import { RealtimeConnectionLimiterService } from "./realtime-connection-limiter.service.js";
import { SkipSuccessEnvelope } from "@bff/core/http/transform.interceptor.js";
@Controller("events") @Controller("events")
export class RealtimeController { export class RealtimeController {
@ -39,6 +40,7 @@ export class RealtimeController {
@RateLimit({ limit: 30, ttl: 60 }) // protect against reconnect storms / refresh spam @RateLimit({ limit: 30, ttl: 60 }) // protect against reconnect storms / refresh spam
@Header("Cache-Control", "private, no-store") @Header("Cache-Control", "private, no-store")
@Header("X-Accel-Buffering", "no") // nginx: disable response buffering for SSE @Header("X-Accel-Buffering", "no") // nginx: disable response buffering for SSE
@SkipSuccessEnvelope()
async stream(@Request() req: RequestWithUser): Promise<Observable<MessageEvent>> { async stream(@Request() req: RequestWithUser): Promise<Observable<MessageEvent>> {
if (!this.limiter.tryAcquire(req.user.id)) { if (!this.limiter.tryAcquire(req.user.id)) {
throw new HttpException( throw new HttpException(

View File

@ -1,18 +1,12 @@
import { Body, Controller, Get, Header, Post, Req, UseGuards, UsePipes } from "@nestjs/common"; import { Body, Controller, Get, Header, Post, Req, UseGuards } from "@nestjs/common";
import { ZodValidationPipe } from "nestjs-zod"; import { createZodDto } from "nestjs-zod";
import { z } from "zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { InternetServicesService } from "./services/internet-services.service.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 type { InternetEligibilityDetails } from "@customer-portal/domain/services";
import { internetEligibilityRequestSchema } from "@customer-portal/domain/services";
const eligibilityRequestSchema = z.object({ class EligibilityRequestDto extends createZodDto(internetEligibilityRequestSchema) {}
notes: z.string().trim().max(2000).optional(),
address: addressSchema.partial().optional(),
});
type EligibilityRequest = z.infer<typeof eligibilityRequestSchema>;
/** /**
* Internet Eligibility Controller * Internet Eligibility Controller
@ -38,11 +32,10 @@ export class InternetEligibilityController {
@Post("eligibility-request") @Post("eligibility-request")
@RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP @RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP
@UsePipes(new ZodValidationPipe(eligibilityRequestSchema))
@Header("Cache-Control", "private, no-store") @Header("Cache-Control", "private, no-store")
async requestEligibility( async requestEligibility(
@Req() req: RequestWithUser, @Req() req: RequestWithUser,
@Body() body: EligibilityRequest @Body() body: EligibilityRequestDto
): Promise<{ requestId: string }> { ): Promise<{ requestId: string }> {
const requestId = await this.internetCatalog.requestEligibilityCheckForUser(req.user.id, { const requestId = await this.internetCatalog.requestEligibilityCheckForUser(req.user.id, {
email: req.user.email, email: req.user.email,

View File

@ -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 type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SimOrderActivationService } from "./sim-order-activation.service.js"; import { SimOrderActivationService } from "./sim-order-activation.service.js";
import { ZodValidationPipe } from "nestjs-zod"; import { createZodDto } from "nestjs-zod";
import { import { simOrderActivationRequestSchema } from "@customer-portal/domain/sim";
simOrderActivationRequestSchema,
type SimOrderActivationRequest, class SimOrderActivationRequestDto extends createZodDto(simOrderActivationRequestSchema) {}
} from "@customer-portal/domain/sim";
@Controller("subscriptions/sim/orders") @Controller("subscriptions/sim/orders")
export class SimOrdersController { export class SimOrdersController {
constructor(private readonly activation: SimOrderActivationService) {} constructor(private readonly activation: SimOrderActivationService) {}
@Post("activate") @Post("activate")
@UsePipes(new ZodValidationPipe(simOrderActivationRequestSchema))
async activate( async activate(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Body() body: SimOrderActivationRequest, @Body() body: SimOrderActivationRequestDto,
@Headers("x-idempotency-key") idempotencyKey?: string @Headers("x-idempotency-key") idempotencyKey?: string
) { ) {
const result = await this.activation.activate(req.user.id, body, idempotencyKey); const result = await this.activation.activate(req.user.id, body, idempotencyKey);

View File

@ -7,8 +7,6 @@ import {
Body, Body,
Request, Request,
ParseIntPipe, ParseIntPipe,
BadRequestException,
UsePipes,
Header, Header,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
@ -17,41 +15,51 @@ import { SubscriptionsService } from "./subscriptions.service.js";
import { SimManagementService } from "./sim-management.service.js"; import { SimManagementService } from "./sim-management.service.js";
import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.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 { import type {
Subscription, Subscription,
SubscriptionList, SubscriptionList,
SubscriptionStats, SubscriptionStats,
SimActionResponse, SimActionResponse,
SimPlanChangeResult, SimPlanChangeResult,
SubscriptionQuery,
} from "@customer-portal/domain/subscriptions"; } from "@customer-portal/domain/subscriptions";
import type { InvoiceList } from "@customer-portal/domain/billing"; import type { InvoiceList } from "@customer-portal/domain/billing";
import type { ApiSuccessResponse } from "@customer-portal/domain/common"; 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 { createPaginationSchema } from "@customer-portal/domain/toolkit/validation/helpers";
import type { z } from "zod";
import { import {
simTopupRequestSchema, simTopupRequestSchema,
simChangePlanRequestSchema, simChangePlanRequestSchema,
simCancelRequestSchema, simCancelRequestSchema,
simFeaturesRequestSchema, simFeaturesRequestSchema,
simTopUpHistoryRequestSchema,
simCancelFullRequestSchema, simCancelFullRequestSchema,
simChangePlanFullRequestSchema, simChangePlanFullRequestSchema,
simReissueFullRequestSchema, simReissueFullRequestSchema,
type SimTopupRequest, simHistoryQuerySchema,
type SimChangePlanRequest, simSftpListQuerySchema,
type SimCancelRequest, simCallHistoryImportQuerySchema,
type SimFeaturesRequest, simTopUpPricingPreviewRequestSchema,
type SimCancelFullRequest, simReissueEsimRequestSchema,
type SimChangePlanFullRequest, simInfoSchema,
simDetailsSchema,
simUsageSchema,
simTopUpHistorySchema,
type SimAvailablePlan, type SimAvailablePlan,
type SimCancellationPreview, type SimCancellationPreview,
type SimDomesticCallHistoryResponse, type SimDomesticCallHistoryResponse,
type SimInternationalCallHistoryResponse, type SimInternationalCallHistoryResponse,
type SimSmsHistoryResponse, type SimSmsHistoryResponse,
type SimReissueFullRequest,
} from "@customer-portal/domain/sim"; } 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 type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SimPlanService } from "./sim-management/services/sim-plan.service.js"; import { SimPlanService } from "./sim-management/services/sim-plan.service.js";
import { SimCancellationService } from "./sim-management/services/sim-cancellation.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 { InternetCancellationService } from "./internet-management/services/internet-cancellation.service.js";
import { import {
internetCancelRequestSchema, internetCancelRequestSchema,
type InternetCancelRequest,
type SimActionResponse as SubscriptionActionResponse, type SimActionResponse as SubscriptionActionResponse,
} from "@customer-portal/domain/subscriptions"; } from "@customer-portal/domain/subscriptions";
import { invoiceListSchema } from "@customer-portal/domain/billing";
const subscriptionInvoiceQuerySchema = createPaginationSchema({ const subscriptionInvoiceQuerySchema = createPaginationSchema({
defaultLimit: 10, defaultLimit: 10,
maxLimit: 100, maxLimit: 100,
minLimit: 1, minLimit: 1,
}); });
type SubscriptionInvoiceQuery = z.infer<typeof subscriptionInvoiceQuerySchema>;
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") @Controller("subscriptions")
export class SubscriptionsController { export class SubscriptionsController {
@ -87,10 +127,10 @@ export class SubscriptionsController {
@Get() @Get()
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
@UsePipes(new ZodValidationPipe(subscriptionQuerySchema)) @ZodResponse({ description: "List subscriptions", type: SubscriptionListDto })
async getSubscriptions( async getSubscriptions(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Query() query: SubscriptionQuery @Query() query: SubscriptionQueryDto
): Promise<SubscriptionList> { ): Promise<SubscriptionList> {
const { status } = query; const { status } = query;
return this.subscriptionsService.getSubscriptions(req.user.id, { status }); return this.subscriptionsService.getSubscriptions(req.user.id, { status });
@ -98,12 +138,14 @@ export class SubscriptionsController {
@Get("active") @Get("active")
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
@ZodResponse({ description: "List active subscriptions", type: [SubscriptionDto] })
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> { async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
return this.subscriptionsService.getActiveSubscriptions(req.user.id); return this.subscriptionsService.getActiveSubscriptions(req.user.id);
} }
@Get("stats") @Get("stats")
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
@ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto })
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> { async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
return this.subscriptionsService.getSubscriptionStats(req.user.id); return this.subscriptionsService.getSubscriptionStats(req.user.id);
} }
@ -126,12 +168,10 @@ export class SubscriptionsController {
*/ */
@UseGuards(AdminGuard) @UseGuards(AdminGuard)
@Get("sim/call-history/sftp-files") @Get("sim/call-history/sftp-files")
async listSftpFiles(@Query("path") path: string = "/home/PASI") { async listSftpFiles(@Query() query: SimSftpListQueryDto) {
if (!path.startsWith("/home/PASI")) { const parsedQuery = simSftpListQuerySchema.parse(query as unknown);
throw new BadRequestException("Invalid path"); const files = await this.simCallHistoryService.listSftpFiles(parsedQuery.path);
} return { success: true, data: files, path: parsedQuery.path };
const files = await this.simCallHistoryService.listSftpFiles(path);
return { success: true, data: files, path };
} }
/** /**
@ -139,12 +179,9 @@ export class SubscriptionsController {
*/ */
@UseGuards(AdminGuard) @UseGuards(AdminGuard)
@Post("sim/call-history/import") @Post("sim/call-history/import")
async importCallHistory(@Query("month") yearMonth: string) { async importCallHistory(@Query() query: SimCallHistoryImportQueryDto) {
if (!yearMonth || !/^\d{6}$/.test(yearMonth)) { const parsedQuery = simCallHistoryImportQuerySchema.parse(query as unknown);
throw new BadRequestException("Invalid month format (expected YYYYMM)"); const result = await this.simCallHistoryService.importCallHistory(parsedQuery.month);
}
const result = await this.simCallHistoryService.importCallHistory(yearMonth);
return { return {
success: true, success: true,
message: `Imported ${result.domestic} domestic calls, ${result.international} international calls, ${result.sms} SMS`, 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") @Get("sim/top-up/pricing/preview")
@Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing calculation is deterministic @Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing calculation is deterministic
async previewSimTopUpPricing(@Query("quotaMb") quotaMb: string) { async previewSimTopUpPricing(@Query() query: SimTopUpPricingPreviewRequestDto) {
const quotaMbNum = parseInt(quotaMb, 10); const preview = await this.simTopUpPricingService.calculatePricingPreview(query.quotaMb);
if (isNaN(quotaMbNum) || quotaMbNum <= 0) {
throw new BadRequestException("Invalid quotaMb parameter");
}
const preview = await this.simTopUpPricingService.calculatePricingPreview(quotaMbNum);
return { success: true, data: preview }; return { success: true, data: preview };
} }
@ -181,6 +213,7 @@ export class SubscriptionsController {
@Get(":id") @Get(":id")
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific @Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
@ZodResponse({ description: "Get subscription", type: SubscriptionDto })
async getSubscriptionById( async getSubscriptionById(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -190,10 +223,11 @@ export class SubscriptionsController {
@Get(":id/invoices") @Get(":id/invoices")
@Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments @Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments
@ZodResponse({ description: "Get subscription invoices", type: InvoiceListDto })
async getSubscriptionInvoices( async getSubscriptionInvoices(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery @Query() query: SubscriptionInvoiceQueryDto
): Promise<InvoiceList> { ): Promise<InvoiceList> {
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query); return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
} }
@ -210,6 +244,7 @@ export class SubscriptionsController {
} }
@Get(":id/sim") @Get(":id/sim")
@ZodResponse({ description: "Get SIM info", type: SimInfoDto })
async getSimInfo( async getSimInfo(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -218,6 +253,7 @@ export class SubscriptionsController {
} }
@Get(":id/sim/details") @Get(":id/sim/details")
@ZodResponse({ description: "Get SIM details", type: SimDetailsDto })
async getSimDetails( async getSimDetails(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -226,6 +262,7 @@ export class SubscriptionsController {
} }
@Get(":id/sim/usage") @Get(":id/sim/usage")
@ZodResponse({ description: "Get SIM usage", type: SimUsageDto })
async getSimUsage( async getSimUsage(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -234,39 +271,32 @@ export class SubscriptionsController {
} }
@Get(":id/sim/top-up-history") @Get(":id/sim/top-up-history")
@ZodResponse({ description: "Get SIM top-up history", type: SimTopUpHistoryDto })
async getSimTopUpHistory( async getSimTopUpHistory(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Query("fromDate") fromDate: string, @Query() query: SimTopUpHistoryRequestDto
@Query("toDate") toDate: string
) { ) {
if (!fromDate || !toDate) { return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, query);
throw new BadRequestException("fromDate and toDate are required");
}
return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, {
fromDate,
toDate,
});
} }
@Post(":id/sim/top-up") @Post(":id/sim/top-up")
@UsePipes(new ZodValidationPipe(simTopupRequestSchema)) @ZodResponse({ description: "Top up SIM", type: SimActionResponseDto })
async topUpSim( async topUpSim(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimTopupRequest @Body() body: SimTopupRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body); await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
return { success: true, message: "SIM top-up completed successfully" }; return { success: true, message: "SIM top-up completed successfully" };
} }
@Post(":id/sim/change-plan") @Post(":id/sim/change-plan")
@UsePipes(new ZodValidationPipe(simChangePlanRequestSchema)) @ZodResponse({ description: "Change SIM plan", type: SimPlanChangeResultDto })
async changeSimPlan( async changeSimPlan(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimChangePlanRequest @Body() body: SimChangePlanRequestDto
): Promise<SimPlanChangeResult> { ): Promise<SimPlanChangeResult> {
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body); const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
return { return {
@ -277,11 +307,11 @@ export class SubscriptionsController {
} }
@Post(":id/sim/cancel") @Post(":id/sim/cancel")
@UsePipes(new ZodValidationPipe(simCancelRequestSchema)) @ZodResponse({ description: "Cancel SIM", type: SimActionResponseDto })
async cancelSim( async cancelSim(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimCancelRequest @Body() body: SimCancelRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.simManagementService.cancelSim(req.user.id, subscriptionId, body); await this.simManagementService.cancelSim(req.user.id, subscriptionId, body);
return { success: true, message: "SIM cancellation completed successfully" }; return { success: true, message: "SIM cancellation completed successfully" };
@ -291,18 +321,23 @@ export class SubscriptionsController {
async reissueEsimProfile( async reissueEsimProfile(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: { newEid?: string } = {} @Body() body: SimReissueEsimRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
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" }; return { success: true, message: "eSIM profile reissue completed successfully" };
} }
@Post(":id/sim/features") @Post(":id/sim/features")
@UsePipes(new ZodValidationPipe(simFeaturesRequestSchema)) @ZodResponse({ description: "Update SIM features", type: SimActionResponseDto })
async updateSimFeatures( async updateSimFeatures(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimFeaturesRequest @Body() body: SimFeaturesRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body); await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
return { success: true, message: "SIM features updated successfully" }; 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) * Change SIM plan with enhanced flow (Salesforce SKU mapping + email notifications)
*/ */
@Post(":id/sim/change-plan-full") @Post(":id/sim/change-plan-full")
@UsePipes(new ZodValidationPipe(simChangePlanFullRequestSchema)) @ZodResponse({ description: "Change SIM plan (full)", type: SimPlanChangeResultDto })
async changeSimPlanFull( async changeSimPlanFull(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimChangePlanFullRequest @Body() body: SimChangePlanFullRequestDto
): Promise<SimPlanChangeResult> { ): Promise<SimPlanChangeResult> {
const result = await this.simPlanService.changeSimPlanFull(req.user.id, subscriptionId, body); const result = await this.simPlanService.changeSimPlanFull(req.user.id, subscriptionId, body);
return { return {
@ -361,11 +396,11 @@ export class SubscriptionsController {
* Cancel SIM with full flow (PA02-04 + email notifications) * Cancel SIM with full flow (PA02-04 + email notifications)
*/ */
@Post(":id/sim/cancel-full") @Post(":id/sim/cancel-full")
@UsePipes(new ZodValidationPipe(simCancelFullRequestSchema)) @ZodResponse({ description: "Cancel SIM (full)", type: SimActionResponseDto })
async cancelSimFull( async cancelSimFull(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimCancelFullRequest @Body() body: SimCancelFullRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.simCancellationService.cancelSimFull(req.user.id, subscriptionId, body); await this.simCancellationService.cancelSimFull(req.user.id, subscriptionId, body);
return { return {
@ -378,11 +413,11 @@ export class SubscriptionsController {
* Reissue SIM (both eSIM and physical SIM) * Reissue SIM (both eSIM and physical SIM)
*/ */
@Post(":id/sim/reissue") @Post(":id/sim/reissue")
@UsePipes(new ZodValidationPipe(simReissueFullRequestSchema)) @ZodResponse({ description: "Reissue SIM", type: SimActionResponseDto })
async reissueSim( async reissueSim(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimReissueFullRequest @Body() body: SimReissueFullRequestDto
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.esimManagementService.reissueSim(req.user.id, subscriptionId, body); await this.esimManagementService.reissueSim(req.user.id, subscriptionId, body);
@ -403,6 +438,10 @@ export class SubscriptionsController {
*/ */
@Get(":id/internet/cancellation-preview") @Get(":id/internet/cancellation-preview")
@Header("Cache-Control", "private, max-age=60") @Header("Cache-Control", "private, max-age=60")
@ZodResponse({
description: "Get internet cancellation preview",
type: InternetCancellationPreviewResponseDto,
})
async getInternetCancellationPreview( async getInternetCancellationPreview(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
@ -411,18 +450,18 @@ export class SubscriptionsController {
req.user.id, req.user.id,
subscriptionId subscriptionId
); );
return { success: true, data: preview }; return { success: true as const, data: preview };
} }
/** /**
* Submit Internet cancellation request * Submit Internet cancellation request
*/ */
@Post(":id/internet/cancel") @Post(":id/internet/cancel")
@UsePipes(new ZodValidationPipe(internetCancelRequestSchema)) @ZodResponse({ description: "Cancel internet", type: SimActionResponseDto })
async cancelInternet( async cancelInternet(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: InternetCancelRequest @Body() body: InternetCancelRequestDto
): Promise<SubscriptionActionResponse> { ): Promise<SubscriptionActionResponse> {
await this.internetCancellationService.submitCancellation(req.user.id, subscriptionId, body); await this.internetCancellationService.submitCancellation(req.user.id, subscriptionId, body);
return { return {
@ -441,26 +480,15 @@ export class SubscriptionsController {
async getDomesticCallHistory( async getDomesticCallHistory(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Query("month") month?: string, @Query() query: SimHistoryQueryDto
@Query("page") page?: string,
@Query("limit") limit?: string
): Promise<ApiSuccessResponse<SimDomesticCallHistoryResponse>> { ): Promise<ApiSuccessResponse<SimDomesticCallHistoryResponse>> {
const pageNum = parseInt(page || "1", 10); const parsedQuery = simHistoryQuerySchema.parse(query as unknown);
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 result = await this.simCallHistoryService.getDomesticCallHistory( const result = await this.simCallHistoryService.getDomesticCallHistory(
req.user.id, req.user.id,
subscriptionId, subscriptionId,
month, parsedQuery.month,
pageNum, parsedQuery.page,
limitNum parsedQuery.limit
); );
return { success: true, data: result }; return { success: true, data: result };
} }
@ -473,26 +501,15 @@ export class SubscriptionsController {
async getInternationalCallHistory( async getInternationalCallHistory(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Query("month") month?: string, @Query() query: SimHistoryQueryDto
@Query("page") page?: string,
@Query("limit") limit?: string
): Promise<ApiSuccessResponse<SimInternationalCallHistoryResponse>> { ): Promise<ApiSuccessResponse<SimInternationalCallHistoryResponse>> {
const pageNum = parseInt(page || "1", 10); const parsedQuery = simHistoryQuerySchema.parse(query as unknown);
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 result = await this.simCallHistoryService.getInternationalCallHistory( const result = await this.simCallHistoryService.getInternationalCallHistory(
req.user.id, req.user.id,
subscriptionId, subscriptionId,
month, parsedQuery.month,
pageNum, parsedQuery.page,
limitNum parsedQuery.limit
); );
return { success: true, data: result }; return { success: true, data: result };
} }
@ -505,26 +522,15 @@ export class SubscriptionsController {
async getSmsHistory( async getSmsHistory(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Query("month") month?: string, @Query() query: SimHistoryQueryDto
@Query("page") page?: string,
@Query("limit") limit?: string
): Promise<ApiSuccessResponse<SimSmsHistoryResponse>> { ): Promise<ApiSuccessResponse<SimSmsHistoryResponse>> {
const pageNum = parseInt(page || "1", 10); const parsedQuery = simHistoryQuerySchema.parse(query as unknown);
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 result = await this.simCallHistoryService.getSmsHistory( const result = await this.simCallHistoryService.getSmsHistory(
req.user.id, req.user.id,
subscriptionId, subscriptionId,
month, parsedQuery.month,
pageNum, parsedQuery.page,
limitNum parsedQuery.limit
); );
return { success: true, data: result }; return { success: true, data: result };
} }

View File

@ -11,23 +11,32 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SupportService } from "./support.service.js"; 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 { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { import {
supportCaseFilterSchema, supportCaseFilterSchema,
supportCaseListSchema,
supportCaseSchema,
createCaseRequestSchema, createCaseRequestSchema,
createCaseResponseSchema,
publicContactRequestSchema, publicContactRequestSchema,
type SupportCaseFilter,
type SupportCaseList, type SupportCaseList,
type SupportCase, type SupportCase,
type CreateCaseRequest,
type CreateCaseResponse, type CreateCaseResponse,
type PublicContactRequest,
} from "@customer-portal/domain/support"; } from "@customer-portal/domain/support";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { hashEmailForLogs } from "./support.logging.js"; import { hashEmailForLogs } from "./support.logging.js";
import type { ApiSuccessMessageResponse } from "@customer-portal/domain/common"; 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") @Controller("support")
export class SupportController { export class SupportController {
@ -37,15 +46,16 @@ export class SupportController {
) {} ) {}
@Get("cases") @Get("cases")
@ZodResponse({ description: "List support cases", type: SupportCaseListDto })
async listCases( async listCases(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Query(new ZodValidationPipe(supportCaseFilterSchema)) @Query() filters: SupportCaseFilterDto
filters: SupportCaseFilter
): Promise<SupportCaseList> { ): Promise<SupportCaseList> {
return this.supportService.listCases(req.user.id, filters); return this.supportService.listCases(req.user.id, filters);
} }
@Get("cases/:id") @Get("cases/:id")
@ZodResponse({ description: "Get support case", type: SupportCaseDto })
async getCase( async getCase(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id") caseId: string @Param("id") caseId: string
@ -54,10 +64,10 @@ export class SupportController {
} }
@Post("cases") @Post("cases")
@ZodResponse({ description: "Create support case", type: CreateCaseResponseDto })
async createCase( async createCase(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Body(new ZodValidationPipe(createCaseRequestSchema)) @Body() body: CreateCaseRequestDto
body: CreateCaseRequest
): Promise<CreateCaseResponse> { ): Promise<CreateCaseResponse> {
return this.supportService.createCase(req.user.id, body); return this.supportService.createCase(req.user.id, body);
} }
@ -72,17 +82,18 @@ export class SupportController {
@Public() @Public()
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes @RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes
async publicContact( @ZodResponse({
@Body(new ZodValidationPipe(publicContactRequestSchema)) description: "Public contact form submission",
body: PublicContactRequest type: ApiSuccessMessageResponseDto,
): Promise<ApiSuccessMessageResponse> { })
async publicContact(@Body() body: PublicContactRequestDto): Promise<ApiSuccessMessageResponse> {
this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) }); this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) });
try { try {
await this.supportService.createPublicContactRequest(body); await this.supportService.createPublicContactRequest(body);
return { return {
success: true, success: true as const,
message: "Your message has been received. We will get back to you within 24 hours.", message: "Your message has been received. We will get back to you within 24 hours.",
}; };
} catch (error) { } catch (error) {

View File

@ -6,20 +6,24 @@ import {
Req, Req,
UseInterceptors, UseInterceptors,
ClassSerializerInterceptor, ClassSerializerInterceptor,
UsePipes,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { UsersFacade } from "./application/users.facade.js"; import { UsersFacade } from "./application/users.facade.js";
import { ZodValidationPipe } from "nestjs-zod"; import { createZodDto, ZodResponse, ZodSerializerDto } from "nestjs-zod";
import { import { updateCustomerProfileRequestSchema } from "@customer-portal/domain/auth";
updateCustomerProfileRequestSchema, import { dashboardSummarySchema } from "@customer-portal/domain/dashboard";
type UpdateCustomerProfileRequest, import { addressSchema, userSchema } from "@customer-portal/domain/customer";
} from "@customer-portal/domain/auth";
import { addressSchema } from "@customer-portal/domain/customer";
import type { Address } 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 type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.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") @Controller("me")
@UseInterceptors(ClassSerializerInterceptor) @UseInterceptors(ClassSerializerInterceptor)
export class UsersController { export class UsersController {
@ -31,8 +35,10 @@ export class UsersController {
*/ */
@UseGuards(SalesforceReadThrottleGuard) @UseGuards(SalesforceReadThrottleGuard)
@Get() @Get()
async getProfile(@Req() req: RequestWithUser) { @ZodResponse({ description: "Get user profile", type: UserDto })
return this.usersFacade.findById(req.user.id); async getProfile(@Req() req: RequestWithUser): Promise<User> {
// 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) @UseGuards(SalesforceReadThrottleGuard)
@Get("summary") @Get("summary")
@ZodResponse({ description: "Get dashboard summary", type: DashboardSummaryDto })
async getSummary(@Req() req: RequestWithUser) { async getSummary(@Req() req: RequestWithUser) {
return this.usersFacade.getUserSummary(req.user.id); return this.usersFacade.getUserSummary(req.user.id);
} }
@ -49,6 +56,7 @@ export class UsersController {
*/ */
@UseGuards(SalesforceReadThrottleGuard) @UseGuards(SalesforceReadThrottleGuard)
@Get("address") @Get("address")
@ZodSerializerDto(addressSchema.nullable())
async getAddress(@Req() req: RequestWithUser): Promise<Address | null> { async getAddress(@Req() req: RequestWithUser): Promise<Address | null> {
return this.usersFacade.getAddress(req.user.id); return this.usersFacade.getAddress(req.user.id);
} }
@ -57,10 +65,10 @@ export class UsersController {
* PATCH /me/address - Update address fields * PATCH /me/address - Update address fields
*/ */
@Patch("address") @Patch("address")
@UsePipes(new ZodValidationPipe(addressSchema.partial())) @ZodResponse({ description: "Update address", type: AddressDto })
async updateAddress( async updateAddress(
@Req() req: RequestWithUser, @Req() req: RequestWithUser,
@Body() address: Partial<Address> @Body() address: UpdateAddressDto
): Promise<Address> { ): Promise<Address> {
return this.usersFacade.updateAddress(req.user.id, address); return this.usersFacade.updateAddress(req.user.id, address);
} }
@ -76,10 +84,10 @@ export class UsersController {
* - Update both: { firstname: "John", address1: "123 Main St" } * - Update both: { firstname: "John", address1: "123 Main St" }
*/ */
@Patch() @Patch()
@UsePipes(new ZodValidationPipe(updateCustomerProfileRequestSchema)) @ZodResponse({ description: "Update profile", type: UserDto })
async updateProfile( async updateProfile(
@Req() req: RequestWithUser, @Req() req: RequestWithUser,
@Body() updateData: UpdateCustomerProfileRequest @Body() updateData: UpdateCustomerProfileRequestDto
) { ) {
return this.usersFacade.updateProfile(req.user.id, updateData); return this.usersFacade.updateProfile(req.user.id, updateData);
} }

View File

@ -13,6 +13,7 @@ export * from "./response-helpers";
// Import createClient for internal use // Import createClient for internal use
import { createClient, ApiError } from "./runtime/client"; import { createClient, ApiError } from "./runtime/client";
import { parseDomainError } from "./response-helpers";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
/** /**
@ -45,6 +46,11 @@ function isAuthEndpoint(url: string): boolean {
* Handles both `{ message }` and `{ error: { message } }` formats * Handles both `{ message }` and `{ error: { message } }` formats
*/ */
function extractErrorMessage(body: unknown): string | null { function extractErrorMessage(body: unknown): string | null {
const domainError = parseDomainError(body);
if (domainError) {
return domainError.error.message;
}
if (!body || typeof body !== "object") { if (!body || typeof body !== "object") {
return null; return null;
} }

View File

@ -1,4 +1,4 @@
import type { ApiResponse } from "../response-helpers"; import { parseDomainError, type ApiResponse } from "../response-helpers";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
export class ApiError extends Error { export class ApiError extends Error {
@ -132,6 +132,11 @@ const getBodyMessage = (body: unknown): string | null => {
return body; return body;
} }
const domainError = parseDomainError(body);
if (domainError) {
return domainError.error.message;
}
if (body && typeof body === "object" && "message" in body) { if (body && typeof body === "object" && "message" in body) {
const maybeMessage = (body as { message?: unknown }).message; const maybeMessage = (body as { message?: unknown }).message;
if (typeof maybeMessage === "string") { if (typeof maybeMessage === "string") {

View File

@ -12,6 +12,7 @@ import {
ErrorMetadata, ErrorMetadata,
type ErrorCodeType, type ErrorCodeType,
} from "@customer-portal/domain/common"; } from "@customer-portal/domain/common";
import { parseDomainError } from "@/lib/api";
// ============================================================================ // ============================================================================
// Types // Types
@ -69,30 +70,21 @@ function parseApiError(error: ClientApiError): ParsedError {
const body = error.body; const body = error.body;
const status = error.response?.status; const status = error.response?.status;
// Try to extract from standard API error response format const domainError = parseDomainError(body);
if (body && typeof body === "object") { if (domainError) {
const bodyObj = body as Record<string, unknown>; 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 const metadata = ErrorMetadata[resolvedCode] ?? ErrorMetadata[ErrorCode.UNKNOWN];
if (bodyObj.success === false && bodyObj.error && typeof bodyObj.error === "object") {
const errorObj = bodyObj.error as Record<string, unknown>;
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 { return {
code: code as ErrorCodeType, code: resolvedCode,
message, message: domainError.error.message,
shouldLogout: metadata.shouldLogout, shouldLogout: metadata.shouldLogout,
shouldRetry: metadata.shouldRetry, 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.
}
// Fall back to status code mapping // Fall back to status code mapping
const code = mapStatusToErrorCode(status); const code = mapStatusToErrorCode(status);

View File

@ -102,8 +102,9 @@ Feature guides explaining how the portal functions:
### BFF (Backend for Frontend) ### BFF (Backend for Frontend)
| Document | Description | | Document | Description |
| ----------------------------------------------------------------- | --------------------------- | | ----------------------------------------------------------------- | ---------------------------- |
| [Integration Patterns](./development/bff/integration-patterns.md) | Clean architecture patterns | | [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 | | [DB Mappers](./development/bff/db-mappers.md) | Database mapping |
| [Order Status Updates](./development/bff/order-status-updates.md) | Status update strategy | | [Order Status Updates](./development/bff/order-status-updates.md) | Status update strategy |

View File

@ -40,9 +40,9 @@ BFF (NestJS)
## Validation Workflow (Zod-First) ## Validation Workflow (Zod-First)
- Shared schemas live in `packages/domain/src/validation`. - Shared schemas live in `packages/domain/<domain>/schema.ts` (and helpers in `packages/domain/toolkit/validation/`).
- Backend registers `nestjs-zod`'s `ZodValidationPipe` globally via `APP_PIPE`. - BFF registers `nestjs-zod`'s `ZodValidationPipe` globally via `APP_PIPE` (see `apps/bff/src/app.module.ts`).
- Controllers compose schemas by importing contracts from the shared package. For query params and body validation, use `@UsePipes(new ZodValidationPipe(schema))`. - 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. - 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. - 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`. - Error handling runs through custom filters; if custom formatting is needed for Zod errors, catch `ZodValidationException`.

View File

@ -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 modules `schema.ts`.
Example (conceptual):
```ts
// packages/domain/<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/<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)

View File

@ -236,13 +236,13 @@ Ask these questions:
## 📋 Examples with Decisions ## 📋 Examples with Decisions
| Utility | Decision | Location | Why | | Utility | Decision | Location | Why |
| ---------------------------------- | --------------------- | --------------------------------- | -------------------------- | | ---------------------------------- | ------------- | --------------------------------- | -------------------------- |
| `formatCurrency(amount, currency)` | ✅ Domain | `domain/toolkit/formatting/` | Pure function, no deps | | `formatCurrency(amount, currency)` | ✅ Domain | `domain/toolkit/formatting/` | Pure function, no deps |
| `invoiceQueryParamsSchema` | ✅ Domain | `domain/billing/schema.ts` | Domain-specific validation | | `invoiceQueryParamsSchema` | ✅ Domain | `domain/billing/schema.ts` | Domain-specific validation |
| `paginationParamsSchema` | ✅ Domain | `domain/common/schema.ts` | Truly generic | | `paginationParamsSchema` | ✅ Domain | `domain/common/schema.ts` | Truly generic |
| `useInvoices()` React hook | ❌ Portal App | `portal/lib/hooks/` | React-specific | | `useInvoices()` React hook | ❌ Portal App | `portal/lib/hooks/` | React-specific |
| `apiClient` instance | ❌ Portal App | `portal/lib/api/` | Framework-specific | | `apiClient` instance | ❌ Portal App | `portal/lib/api/` | Framework-specific |
| `ZodValidationPipe` | ✅ Validation Package | `packages/validation/` | Reusable bridge | | `ZodValidationPipe` | ❌ BFF App | `apps/bff/src/app.module.ts` | NestJS/framework-specific |
| `getErrorMessage(error)` | ❌ BFF App | `bff/core/utils/` | App-specific utility | | `getErrorMessage(error)` | ❌ BFF App | `bff/core/utils/` | App-specific utility |
| `transformWhmcsInvoice()` | ✅ Domain | `domain/billing/providers/whmcs/` | Data transformation | | `transformWhmcsInvoice()` | ✅ Domain | `domain/billing/providers/whmcs/` | Data transformation |

View File

@ -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 // Node globals for tooling/config files
// ============================================================================= // =============================================================================

View File

@ -25,6 +25,8 @@ export type {
BillingSummary, BillingSummary,
InvoiceQueryParams, InvoiceQueryParams,
InvoiceListQuery, InvoiceListQuery,
InvoiceSsoQuery,
InvoicePaymentLinkQuery,
} from "./schema.js"; } from "./schema.js";
// Provider adapters // Provider adapters

View File

@ -119,6 +119,25 @@ export const invoiceListQuerySchema = z.object({
export type InvoiceListQuery = z.infer<typeof invoiceListQuerySchema>; export type InvoiceListQuery = z.infer<typeof invoiceListQuerySchema>;
/**
* 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<typeof invoiceSsoQuerySchema>;
/**
* 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<typeof invoicePaymentLinkQuerySchema>;
// ============================================================================ // ============================================================================
// Inferred Types from Schemas (Schema-First Approach) // Inferred Types from Schemas (Schema-First Approach)
// ============================================================================ // ============================================================================

View File

@ -18,9 +18,13 @@ export {
notificationSchema, notificationSchema,
createNotificationRequestSchema, createNotificationRequestSchema,
notificationListResponseSchema, notificationListResponseSchema,
notificationUnreadCountResponseSchema,
notificationQuerySchema,
// Types // Types
type Notification, type Notification,
type CreateNotificationRequest, type CreateNotificationRequest,
type NotificationTemplate, type NotificationTemplate,
type NotificationListResponse, type NotificationListResponse,
type NotificationUnreadCountResponse,
type NotificationQuery,
} from "./schema.js"; } from "./schema.js";

View File

@ -207,3 +207,20 @@ export const notificationListResponseSchema = z.object({
}); });
export type NotificationListResponse = z.infer<typeof notificationListResponseSchema>; export type NotificationListResponse = z.infer<typeof notificationListResponseSchema>;
export const notificationUnreadCountResponseSchema = z.object({
count: z.number(),
});
export type NotificationUnreadCountResponse = z.infer<typeof notificationUnreadCountResponseSchema>;
/**
* 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<typeof notificationQuerySchema>;

View File

@ -74,6 +74,7 @@ export type {
CreateOrderRequest, CreateOrderRequest,
OrderBusinessValidation, OrderBusinessValidation,
SfOrderIdParam, SfOrderIdParam,
OrderListResponse,
// Display types // Display types
OrderDisplayItem, OrderDisplayItem,
OrderDisplayItemCategory, OrderDisplayItemCategory,

View File

@ -318,6 +318,46 @@ export const checkoutBuildCartRequestSchema = z.object({
export const checkoutBuildCartResponseSchema = apiSuccessResponseSchema(checkoutCartSchema); 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 * Schema for order creation response
*/ */
@ -327,6 +367,10 @@ export const orderCreateResponseSchema = z.object({
message: z.string(), message: z.string(),
}); });
export const orderListResponseSchema = z.array(orderSummarySchema);
export type OrderListResponse = z.infer<typeof orderListResponseSchema>;
// ============================================================================ // ============================================================================
// Inferred Types from Schemas (Schema-First Approach) // Inferred Types from Schemas (Schema-First Approach)
// ============================================================================ // ============================================================================
@ -347,6 +391,13 @@ export type CreateOrderRequest = z.infer<typeof createOrderRequestSchema>;
export type OrderBusinessValidation = z.infer<typeof orderBusinessValidationSchema>; export type OrderBusinessValidation = z.infer<typeof orderBusinessValidationSchema>;
export type CheckoutBuildCartRequest = z.infer<typeof checkoutBuildCartRequestSchema>; export type CheckoutBuildCartRequest = z.infer<typeof checkoutBuildCartRequestSchema>;
export type CheckoutBuildCartResponse = z.infer<typeof checkoutBuildCartResponseSchema>; export type CheckoutBuildCartResponse = z.infer<typeof checkoutBuildCartResponseSchema>;
export type CheckoutSessionCreateOrderRequest = z.infer<
typeof checkoutSessionCreateOrderRequestSchema
>;
export type CheckoutSessionIdParam = z.infer<typeof checkoutSessionIdParamSchema>;
export type CheckoutCartSummary = z.infer<typeof checkoutCartSummarySchema>;
export type CheckoutSessionResponse = z.infer<typeof checkoutSessionResponseSchema>;
export type CheckoutValidateCartResponse = z.infer<typeof checkoutValidateCartResponseSchema>;
// ============================================================================ // ============================================================================
// Order Display Types (for UI presentation) // Order Display Types (for UI presentation)

View File

@ -28,20 +28,6 @@ export const PAYMENT_GATEWAY_TYPE = {
MANUAL: "manual", MANUAL: "manual",
} as const; } 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) // Re-export Types from Schema (Schema-First Approach)
// ============================================================================ // ============================================================================
@ -53,4 +39,5 @@ export type {
PaymentGatewayType, PaymentGatewayType,
PaymentGateway, PaymentGateway,
PaymentGatewayList, PaymentGatewayList,
InvoicePaymentLink,
} from "./schema.js"; } from "./schema.js";

View File

@ -7,7 +7,7 @@
*/ */
// Constants // 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) // Schemas (includes derived types)
export * from "./schema.js"; export * from "./schema.js";

View File

@ -55,6 +55,12 @@ export const paymentGatewayListSchema = z.object({
totalCount: z.number().int().min(0), 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) // Inferred Types from Schemas (Schema-First Approach)
// ============================================================================ // ============================================================================
@ -65,3 +71,4 @@ export type PaymentMethodList = z.infer<typeof paymentMethodListSchema>;
export type PaymentGatewayType = z.infer<typeof paymentGatewayTypeSchema>; export type PaymentGatewayType = z.infer<typeof paymentGatewayTypeSchema>;
export type PaymentGateway = z.infer<typeof paymentGatewaySchema>; export type PaymentGateway = z.infer<typeof paymentGatewaySchema>;
export type PaymentGatewayList = z.infer<typeof paymentGatewayListSchema>; export type PaymentGatewayList = z.infer<typeof paymentGatewayListSchema>;
export type InvoicePaymentLink = z.infer<typeof invoicePaymentLinkSchema>;

View File

@ -5,6 +5,7 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { addressSchema } from "../customer/index.js";
// ============================================================================ // ============================================================================
// Base Catalog Product Schema // Base Catalog Product Schema
@ -111,6 +112,15 @@ export const internetEligibilityDetailsSchema = z.object({
notes: z.string().nullable(), 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 // SIM Product Schemas
// ============================================================================ // ============================================================================
@ -184,6 +194,7 @@ export type InternetAddonCatalogItem = z.infer<typeof internetAddonCatalogItemSc
export type InternetCatalogCollection = z.infer<typeof internetCatalogCollectionSchema>; export type InternetCatalogCollection = z.infer<typeof internetCatalogCollectionSchema>;
export type InternetEligibilityStatus = z.infer<typeof internetEligibilityStatusSchema>; export type InternetEligibilityStatus = z.infer<typeof internetEligibilityStatusSchema>;
export type InternetEligibilityDetails = z.infer<typeof internetEligibilityDetailsSchema>; export type InternetEligibilityDetails = z.infer<typeof internetEligibilityDetailsSchema>;
export type InternetEligibilityRequest = z.infer<typeof internetEligibilityRequestSchema>;
// SIM products // SIM products
export type SimCatalogProduct = z.infer<typeof simCatalogProductSchema>; export type SimCatalogProduct = z.infer<typeof simCatalogProductSchema>;

View File

@ -48,6 +48,10 @@ export type {
SimInternationalCallHistoryResponse, SimInternationalCallHistoryResponse,
SimSmsRecord, SimSmsRecord,
SimSmsHistoryResponse, SimSmsHistoryResponse,
SimHistoryMonth,
SimHistoryAvailableMonths,
SimCallHistoryImportResult,
SimSftpFiles,
// Request types // Request types
SimTopUpRequest, SimTopUpRequest,
SimPlanChangeRequest, SimPlanChangeRequest,
@ -63,6 +67,10 @@ export type {
SimCancelFullRequest, SimCancelFullRequest,
SimTopUpFullRequest, SimTopUpFullRequest,
SimChangePlanFullRequest, SimChangePlanFullRequest,
SimHistoryQuery,
SimSftpListQuery,
SimCallHistoryImportQuery,
SimReissueEsimRequest,
// Activation types // Activation types
SimOrderActivationRequest, SimOrderActivationRequest,
SimOrderActivationMnp, SimOrderActivationMnp,

View File

@ -234,7 +234,24 @@ export type SimReissueFullRequest = z.infer<typeof simReissueFullRequestSchema>;
// SIM Call/SMS History (portal-facing) // 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<typeof simHistoryMonthSchema>;
export const simHistoryAvailableMonthsSchema = z.array(simHistoryMonthSchema);
export type SimHistoryAvailableMonths = z.infer<typeof simHistoryAvailableMonthsSchema>;
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<typeof simCallHistoryImportResultSchema>;
export const simSftpFilesSchema = z.array(z.string());
export type SimSftpFiles = z.infer<typeof simSftpFilesSchema>;
const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"); 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"); 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 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<typeof simHistoryQuerySchema>;
/**
* 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<typeof simSftpListQuerySchema>;
/**
* 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<typeof simCallHistoryImportQuerySchema>;
/**
* 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<typeof simReissueEsimRequestSchema>;
export const simSmsRecordSchema = z.object({ export const simSmsRecordSchema = z.object({
id: z.string(), id: z.string(),
date: isoDateSchema, date: isoDateSchema,