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:
parent
851207b401
commit
10c8461661
@ -1,9 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { APP_PIPE } from "@nestjs/core";
|
||||
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
||||
import { RouterModule } from "@nestjs/core";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod";
|
||||
import { TransformInterceptor } from "@bff/core/http/transform.interceptor.js";
|
||||
|
||||
// Configuration
|
||||
import { appConfig } from "@bff/core/config/app.config.js";
|
||||
@ -105,6 +106,14 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
|
||||
provide: APP_PIPE,
|
||||
useClass: ZodValidationPipe,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: TransformInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ZodSerializerInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { SecureErrorMapperService } from "./services/secure-error-mapper.service.js";
|
||||
import { CsrfService } from "./services/csrf.service.js";
|
||||
import { CsrfMiddleware } from "./middleware/csrf.middleware.js";
|
||||
import { CsrfController } from "./controllers/csrf.controller.js";
|
||||
@ -10,8 +9,8 @@ import { AdminGuard } from "./guards/admin.guard.js";
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [CsrfController],
|
||||
providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware, AdminGuard],
|
||||
exports: [SecureErrorMapperService, CsrfService, AdminGuard],
|
||||
providers: [CsrfService, CsrfMiddleware, AdminGuard],
|
||||
exports: [CsrfService, AdminGuard],
|
||||
})
|
||||
export class SecurityModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,6 @@ import {
|
||||
Get,
|
||||
Req,
|
||||
HttpCode,
|
||||
UsePipes,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
@ -20,7 +19,7 @@ import {
|
||||
} from "./guards/failed-login-throttle.guard.js";
|
||||
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
|
||||
import { Public } from "../../decorators/public.decorator.js";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
||||
@ -43,17 +42,6 @@ import {
|
||||
refreshTokenRequestSchema,
|
||||
checkPasswordNeededResponseSchema,
|
||||
linkWhmcsResponseSchema,
|
||||
type SignupRequest,
|
||||
type PasswordResetRequest,
|
||||
type ResetPasswordRequest,
|
||||
type SetPasswordRequest,
|
||||
type LinkWhmcsRequest,
|
||||
type ChangePasswordRequest,
|
||||
type ValidateSignupRequest,
|
||||
type AccountStatusRequest,
|
||||
type SsoLinkRequest,
|
||||
type CheckPasswordNeededRequest,
|
||||
type RefreshTokenRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
|
||||
type CookieValue = string | undefined;
|
||||
@ -61,6 +49,20 @@ type RequestWithCookies = Omit<Request, "cookies"> & {
|
||||
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 expiresAt = Date.parse(isoTimestamp);
|
||||
if (Number.isNaN(expiresAt)) {
|
||||
@ -128,8 +130,7 @@ export class AuthController {
|
||||
@Post("validate-signup")
|
||||
@UseGuards(RateLimitGuard, SalesforceReadThrottleGuard)
|
||||
@RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP
|
||||
@UsePipes(new ZodValidationPipe(validateSignupRequestSchema))
|
||||
async validateSignup(@Body() validateData: ValidateSignupRequest, @Req() req: Request) {
|
||||
async validateSignup(@Body() validateData: ValidateSignupRequestDto, @Req() req: Request) {
|
||||
return this.authFacade.validateSignup(validateData, req);
|
||||
}
|
||||
|
||||
@ -143,16 +144,14 @@ export class AuthController {
|
||||
@Post("signup-preflight")
|
||||
@UseGuards(RateLimitGuard, SalesforceReadThrottleGuard)
|
||||
@RateLimit({ limit: 20, ttl: 600 }) // 20 validations per 10 minutes per IP
|
||||
@UsePipes(new ZodValidationPipe(signupRequestSchema))
|
||||
@HttpCode(200)
|
||||
async signupPreflight(@Body() signupData: SignupRequest) {
|
||||
async signupPreflight(@Body() signupData: SignupRequestDto) {
|
||||
return this.authFacade.signupPreflight(signupData);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("account-status")
|
||||
@UsePipes(new ZodValidationPipe(accountStatusRequestSchema))
|
||||
async accountStatus(@Body() body: AccountStatusRequest) {
|
||||
async accountStatus(@Body() body: AccountStatusRequestDto) {
|
||||
return this.authFacade.getAccountStatus(body.email);
|
||||
}
|
||||
|
||||
@ -160,9 +159,8 @@ export class AuthController {
|
||||
@Post("signup")
|
||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 900 }) // 5 signups per 15 minutes per IP (reasonable for account creation)
|
||||
@UsePipes(new ZodValidationPipe(signupRequestSchema))
|
||||
async signup(
|
||||
@Body() signupData: SignupRequest,
|
||||
@Body() signupData: SignupRequestDto,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
@ -212,9 +210,8 @@ export class AuthController {
|
||||
@Post("refresh")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
|
||||
@UsePipes(new ZodValidationPipe(refreshTokenRequestSchema))
|
||||
async refreshToken(
|
||||
@Body() body: RefreshTokenRequest,
|
||||
@Body() body: RefreshTokenRequestDto,
|
||||
@Req() req: RequestWithCookies,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
@ -233,19 +230,18 @@ export class AuthController {
|
||||
@Post("migrate")
|
||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
|
||||
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
|
||||
async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
|
||||
@ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto })
|
||||
async migrateAccount(@Body() linkData: LinkWhmcsRequestDto, @Req() _req: Request) {
|
||||
const result = await this.authFacade.linkWhmcsUser(linkData);
|
||||
return linkWhmcsResponseSchema.parse(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("set-password")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard)
|
||||
@UsePipes(new ZodValidationPipe(setPasswordRequestSchema))
|
||||
async setPassword(
|
||||
@Body() setPasswordData: SetPasswordRequest,
|
||||
@Body() setPasswordData: SetPasswordRequestDto,
|
||||
@Req() _req: Request,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
@ -256,19 +252,22 @@ export class AuthController {
|
||||
|
||||
@Public()
|
||||
@Post("check-password-needed")
|
||||
@UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema))
|
||||
@HttpCode(200)
|
||||
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequest) {
|
||||
@ZodResponse({
|
||||
status: 200,
|
||||
description: "Check if password is needed",
|
||||
type: CheckPasswordNeededResponseDto,
|
||||
})
|
||||
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) {
|
||||
const response = await this.authFacade.checkPasswordNeeded(data.email);
|
||||
return checkPasswordNeededResponseSchema.parse(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("request-password-reset")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
|
||||
@UsePipes(new ZodValidationPipe(passwordResetRequestSchema))
|
||||
async requestPasswordReset(@Body() body: PasswordResetRequest, @Req() req: Request) {
|
||||
async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) {
|
||||
await this.authFacade.requestPasswordReset(body.email, req);
|
||||
return { message: "If an account exists, a reset email has been sent" };
|
||||
}
|
||||
@ -278,9 +277,8 @@ export class AuthController {
|
||||
@HttpCode(200)
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
|
||||
@UsePipes(new ZodValidationPipe(passwordResetSchema))
|
||||
async resetPassword(
|
||||
@Body() body: ResetPasswordRequest,
|
||||
@Body() body: ResetPasswordRequestDto,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
await this.authFacade.resetPassword(body.token, body.password);
|
||||
@ -293,10 +291,9 @@ export class AuthController {
|
||||
@Post("change-password")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes
|
||||
@UsePipes(new ZodValidationPipe(changePasswordRequestSchema))
|
||||
async changePassword(
|
||||
@Req() req: Request & { user: { id: string } },
|
||||
@Body() body: ChangePasswordRequest,
|
||||
@Body() body: ChangePasswordRequestDto,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
const result = await this.authFacade.changePassword(req.user.id, body, req);
|
||||
@ -310,10 +307,9 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post("sso-link")
|
||||
@UsePipes(new ZodValidationPipe(ssoLinkRequestSchema))
|
||||
async createSsoLink(
|
||||
@Req() req: Request & { user: { id: string } },
|
||||
@Body() body: SsoLinkRequest
|
||||
@Body() body: SsoLinkRequestDto
|
||||
) {
|
||||
const destination = body?.destination;
|
||||
return this.authFacade.createSsoLink(req.user.id, destination);
|
||||
|
||||
@ -8,32 +8,48 @@ import {
|
||||
ParseIntPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service.js";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
InvoiceSsoLink,
|
||||
InvoiceListQuery,
|
||||
import type { Invoice, InvoiceList, InvoiceSsoLink } from "@customer-portal/domain/billing";
|
||||
import {
|
||||
invoiceListQuerySchema,
|
||||
invoiceListSchema,
|
||||
invoiceSchema,
|
||||
invoiceSsoLinkSchema,
|
||||
invoiceSsoQuerySchema,
|
||||
invoicePaymentLinkQuerySchema,
|
||||
} from "@customer-portal/domain/billing";
|
||||
import { invoiceListQuerySchema, invoiceSchema } from "@customer-portal/domain/billing";
|
||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
import type {
|
||||
PaymentMethodList,
|
||||
PaymentGatewayList,
|
||||
InvoicePaymentLink,
|
||||
} from "@customer-portal/domain/payments";
|
||||
import {
|
||||
paymentMethodListSchema,
|
||||
paymentGatewayListSchema,
|
||||
invoicePaymentLinkSchema,
|
||||
} from "@customer-portal/domain/payments";
|
||||
|
||||
class InvoiceListQueryDto extends createZodDto(invoiceListQuerySchema) {}
|
||||
class InvoiceListDto extends createZodDto(invoiceListSchema) {}
|
||||
class InvoiceDto extends createZodDto(invoiceSchema) {}
|
||||
class InvoiceSsoLinkDto extends createZodDto(invoiceSsoLinkSchema) {}
|
||||
class InvoiceSsoQueryDto extends createZodDto(invoiceSsoQuerySchema) {}
|
||||
class InvoicePaymentLinkQueryDto extends createZodDto(invoicePaymentLinkQuerySchema) {}
|
||||
class PaymentMethodListDto extends createZodDto(paymentMethodListSchema) {}
|
||||
class PaymentGatewayListDto extends createZodDto(paymentGatewayListSchema) {}
|
||||
class InvoicePaymentLinkDto extends createZodDto(invoicePaymentLinkSchema) {}
|
||||
|
||||
/**
|
||||
* Invoice Controller
|
||||
*
|
||||
* All request validation is handled by Zod schemas via ZodValidationPipe.
|
||||
* All request validation is handled by Zod schemas via global ZodValidationPipe.
|
||||
* Business logic is delegated to service layer.
|
||||
*/
|
||||
@Controller("invoices")
|
||||
@ -45,14 +61,16 @@ export class InvoicesController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ZodResponse({ description: "List invoices", type: InvoiceListDto })
|
||||
async getInvoices(
|
||||
@Request() req: RequestWithUser,
|
||||
@Query(new ZodValidationPipe(invoiceListQuerySchema)) query: InvoiceListQuery
|
||||
@Query() query: InvoiceListQueryDto
|
||||
): Promise<InvoiceList> {
|
||||
return this.invoicesService.getInvoices(req.user.id, query);
|
||||
}
|
||||
|
||||
@Get("payment-methods")
|
||||
@ZodResponse({ description: "List payment methods", type: PaymentMethodListDto })
|
||||
async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
||||
const mapping = await this.mappingsService.findByUserId(req.user.id);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
@ -62,12 +80,14 @@ export class InvoicesController {
|
||||
}
|
||||
|
||||
@Get("payment-gateways")
|
||||
@ZodResponse({ description: "List payment gateways", type: PaymentGatewayListDto })
|
||||
async getPaymentGateways(): Promise<PaymentGatewayList> {
|
||||
return this.whmcsService.getPaymentGateways();
|
||||
}
|
||||
|
||||
@Post("payment-methods/refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ZodResponse({ description: "Refresh payment methods", type: PaymentMethodListDto })
|
||||
async refreshPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
||||
// Invalidate cache first
|
||||
await this.whmcsService.invalidatePaymentMethodsCache(req.user.id);
|
||||
@ -81,23 +101,19 @@ export class InvoicesController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ZodResponse({ description: "Get invoice by id", type: InvoiceDto })
|
||||
async getInvoiceById(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) invoiceId: number
|
||||
): Promise<Invoice> {
|
||||
// Validate using domain schema
|
||||
invoiceSchema.shape.id.parse(invoiceId);
|
||||
return this.invoicesService.getInvoiceById(req.user.id, invoiceId);
|
||||
}
|
||||
|
||||
@Get(":id/subscriptions")
|
||||
getInvoiceSubscriptions(
|
||||
@Request() _req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) invoiceId: number
|
||||
@Param("id", ParseIntPipe) _invoiceId: number
|
||||
): Subscription[] {
|
||||
// Validate using domain schema
|
||||
invoiceSchema.shape.id.parse(invoiceId);
|
||||
|
||||
// This functionality has been moved to WHMCS directly
|
||||
// For now, return empty array as subscriptions are managed in WHMCS
|
||||
return [];
|
||||
@ -105,28 +121,23 @@ export class InvoicesController {
|
||||
|
||||
@Post(":id/sso-link")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ZodResponse({ description: "Create invoice SSO link", type: InvoiceSsoLinkDto })
|
||||
async createSsoLink(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) invoiceId: number,
|
||||
@Query("target") target?: "view" | "download" | "pay"
|
||||
@Query() query: InvoiceSsoQueryDto
|
||||
): Promise<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);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new Error("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const parsedQuery = invoiceSsoQuerySchema.parse(query as unknown);
|
||||
|
||||
const ssoUrl = await this.whmcsService.whmcsSsoForInvoice(
|
||||
mapping.whmcsClientId,
|
||||
invoiceId,
|
||||
target || "view"
|
||||
parsedQuery.target
|
||||
);
|
||||
|
||||
return {
|
||||
@ -137,36 +148,30 @@ export class InvoicesController {
|
||||
|
||||
@Post(":id/payment-link")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ZodResponse({ description: "Create invoice payment link", type: InvoicePaymentLinkDto })
|
||||
async createPaymentLink(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) invoiceId: number,
|
||||
@Query("paymentMethodId") paymentMethodId?: string,
|
||||
@Query("gatewayName") gatewayName?: string
|
||||
@Query() query: InvoicePaymentLinkQueryDto
|
||||
): Promise<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);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new Error("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const parsedQuery = invoicePaymentLinkQuerySchema.parse(query as unknown);
|
||||
|
||||
const ssoResult = await this.whmcsService.createPaymentSsoToken(
|
||||
mapping.whmcsClientId,
|
||||
invoiceId,
|
||||
paymentMethodIdNum,
|
||||
gatewayName || "stripe"
|
||||
parsedQuery.paymentMethodId,
|
||||
parsedQuery.gatewayName
|
||||
);
|
||||
|
||||
return {
|
||||
url: ssoResult.url,
|
||||
expiresAt: ssoResult.expiresAt,
|
||||
gatewayName: gatewayName || "stripe",
|
||||
gatewayName: parsedQuery.gatewayName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import { InvoiceHealthService } from "./services/invoice-health.service.js";
|
||||
/**
|
||||
* Invoice Module
|
||||
*
|
||||
* Validation is now handled by Zod schemas via ZodValidationPipe in controller.
|
||||
* Validation is handled by Zod schemas via Zod DTOs + the global ZodValidationPipe (APP_PIPE).
|
||||
* No separate validator service needed.
|
||||
*/
|
||||
@Module({
|
||||
|
||||
@ -4,23 +4,28 @@
|
||||
* API endpoints for managing in-app notifications.
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
ParseBoolPipe,
|
||||
} from "@nestjs/common";
|
||||
import { Controller, Get, Post, Param, Query, Req, UseGuards } from "@nestjs/common";
|
||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { NotificationService } from "./notifications.service.js";
|
||||
import type { NotificationListResponse } from "@customer-portal/domain/notifications";
|
||||
import type { ApiSuccessAckResponse } from "@customer-portal/domain/common";
|
||||
import {
|
||||
notificationListResponseSchema,
|
||||
notificationUnreadCountResponseSchema,
|
||||
type NotificationListResponse,
|
||||
} from "@customer-portal/domain/notifications";
|
||||
import { notificationQuerySchema } from "@customer-portal/domain/notifications";
|
||||
import {
|
||||
apiSuccessAckResponseSchema,
|
||||
type ApiSuccessAckResponse,
|
||||
} from "@customer-portal/domain/common";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
|
||||
class NotificationQueryDto extends createZodDto(notificationQuerySchema) {}
|
||||
class NotificationListResponseDto extends createZodDto(notificationListResponseSchema) {}
|
||||
class NotificationUnreadCountResponseDto extends createZodDto(
|
||||
notificationUnreadCountResponseSchema
|
||||
) {}
|
||||
class ApiSuccessAckResponseDto extends createZodDto(apiSuccessAckResponseSchema) {}
|
||||
|
||||
@Controller("notifications")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@ -32,17 +37,17 @@ export class NotificationsController {
|
||||
*/
|
||||
@Get()
|
||||
@RateLimit({ limit: 60, ttl: 60 })
|
||||
@ZodResponse({ description: "Get notifications", type: NotificationListResponseDto })
|
||||
async getNotifications(
|
||||
@Req() req: RequestWithUser,
|
||||
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
@Query("includeRead", new DefaultValuePipe(true), ParseBoolPipe)
|
||||
includeRead: boolean
|
||||
@Query() query: NotificationQueryDto
|
||||
): Promise<NotificationListResponse> {
|
||||
const parsedQuery = notificationQuerySchema.parse(query as unknown);
|
||||
|
||||
return this.notificationService.getNotifications(req.user.id, {
|
||||
limit: Math.min(limit, 50), // Cap at 50
|
||||
offset,
|
||||
includeRead,
|
||||
limit: Math.min(parsedQuery.limit, 50), // Cap at 50
|
||||
offset: parsedQuery.offset,
|
||||
includeRead: parsedQuery.includeRead,
|
||||
});
|
||||
}
|
||||
|
||||
@ -51,6 +56,7 @@ export class NotificationsController {
|
||||
*/
|
||||
@Get("unread-count")
|
||||
@RateLimit({ limit: 120, ttl: 60 })
|
||||
@ZodResponse({ description: "Get unread count", type: NotificationUnreadCountResponseDto })
|
||||
async getUnreadCount(@Req() req: RequestWithUser): Promise<{ count: number }> {
|
||||
const count = await this.notificationService.getUnreadCount(req.user.id);
|
||||
return { count };
|
||||
@ -61,6 +67,7 @@ export class NotificationsController {
|
||||
*/
|
||||
@Post(":id/read")
|
||||
@RateLimit({ limit: 60, ttl: 60 })
|
||||
@ZodResponse({ description: "Mark as read", type: ApiSuccessAckResponseDto })
|
||||
async markAsRead(
|
||||
@Req() req: RequestWithUser,
|
||||
@Param("id") notificationId: string
|
||||
@ -74,6 +81,7 @@ export class NotificationsController {
|
||||
*/
|
||||
@Post("read-all")
|
||||
@RateLimit({ limit: 10, ttl: 60 })
|
||||
@ZodResponse({ description: "Mark all as read", type: ApiSuccessAckResponseDto })
|
||||
async markAllAsRead(@Req() req: RequestWithUser): Promise<ApiSuccessAckResponse> {
|
||||
await this.notificationService.markAllAsRead(req.user.id);
|
||||
return { success: true };
|
||||
@ -84,6 +92,7 @@ export class NotificationsController {
|
||||
*/
|
||||
@Post(":id/dismiss")
|
||||
@RateLimit({ limit: 60, ttl: 60 })
|
||||
@ZodResponse({ description: "Dismiss notification", type: ApiSuccessAckResponseDto })
|
||||
async dismiss(
|
||||
@Req() req: RequestWithUser,
|
||||
@Param("id") notificationId: string
|
||||
|
||||
@ -1,48 +1,26 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Request,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
Inject,
|
||||
} from "@nestjs/common";
|
||||
import { Body, Controller, Get, Param, Post, Request, UseGuards, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
import { CheckoutService } from "../services/checkout.service.js";
|
||||
import { CheckoutSessionService } from "../services/checkout-session.service.js";
|
||||
import {
|
||||
checkoutItemSchema,
|
||||
checkoutCartSchema,
|
||||
checkoutBuildCartRequestSchema,
|
||||
checkoutBuildCartResponseSchema,
|
||||
checkoutTotalsSchema,
|
||||
checkoutSessionIdParamSchema,
|
||||
checkoutSessionResponseSchema,
|
||||
checkoutValidateCartResponseSchema,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders";
|
||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||
import { z } from "zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
|
||||
const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() }));
|
||||
const checkoutSessionIdParamSchema = z.object({ sessionId: z.string().uuid() });
|
||||
|
||||
const checkoutCartSummarySchema = z.object({
|
||||
items: z.array(checkoutItemSchema),
|
||||
totals: checkoutTotalsSchema,
|
||||
});
|
||||
|
||||
const checkoutSessionResponseSchema = apiSuccessResponseSchema(
|
||||
z.object({
|
||||
sessionId: z.string().uuid(),
|
||||
expiresAt: z.string(),
|
||||
orderType: z.enum(["Internet", "SIM", "VPN", "Other"]),
|
||||
cart: checkoutCartSummarySchema,
|
||||
})
|
||||
);
|
||||
class CheckoutBuildCartRequestDto extends createZodDto(checkoutBuildCartRequestSchema) {}
|
||||
class CheckoutSessionIdParamDto extends createZodDto(checkoutSessionIdParamSchema) {}
|
||||
class CheckoutCartDto extends createZodDto(checkoutCartSchema) {}
|
||||
class CheckoutBuildCartResponseDto extends createZodDto(checkoutBuildCartResponseSchema) {}
|
||||
class CheckoutSessionResponseDto extends createZodDto(checkoutSessionResponseSchema) {}
|
||||
class ValidateCartResponseDto extends createZodDto(checkoutValidateCartResponseSchema) {}
|
||||
|
||||
@Controller("checkout")
|
||||
@Public() // Cart building and validation can be done without authentication
|
||||
@ -55,8 +33,12 @@ export class CheckoutController {
|
||||
|
||||
@Post("cart")
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema))
|
||||
async buildCart(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) {
|
||||
@ZodResponse({
|
||||
status: 201,
|
||||
description: "Build checkout cart",
|
||||
type: CheckoutBuildCartResponseDto,
|
||||
})
|
||||
async buildCart(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequestDto) {
|
||||
this.logger.log("Building checkout cart", {
|
||||
userId: req.user?.id,
|
||||
orderType: body.orderType,
|
||||
@ -70,10 +52,7 @@ export class CheckoutController {
|
||||
req.user?.id
|
||||
);
|
||||
|
||||
return checkoutBuildCartResponseSchema.parse({
|
||||
success: true,
|
||||
data: cart,
|
||||
});
|
||||
return { success: true as const, data: cart };
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to build checkout cart", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@ -90,8 +69,12 @@ export class CheckoutController {
|
||||
*/
|
||||
@Post("session")
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema))
|
||||
async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) {
|
||||
@ZodResponse({
|
||||
status: 201,
|
||||
description: "Create checkout session",
|
||||
type: CheckoutSessionResponseDto,
|
||||
})
|
||||
async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequestDto) {
|
||||
this.logger.log("Creating checkout session", {
|
||||
userId: req.user?.id,
|
||||
orderType: body.orderType,
|
||||
@ -106,8 +89,8 @@ export class CheckoutController {
|
||||
|
||||
const session = await this.checkoutSessions.createSession(body, cart);
|
||||
|
||||
return checkoutSessionResponseSchema.parse({
|
||||
success: true,
|
||||
return {
|
||||
success: true as const,
|
||||
data: {
|
||||
sessionId: session.sessionId,
|
||||
expiresAt: session.expiresAt,
|
||||
@ -117,16 +100,20 @@ export class CheckoutController {
|
||||
totals: cart.totals,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@Get("session/:sessionId")
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@UsePipes(new ZodValidationPipe(checkoutSessionIdParamSchema))
|
||||
async getSession(@Param() params: { sessionId: string }) {
|
||||
@ZodResponse({
|
||||
status: 200,
|
||||
description: "Get checkout session",
|
||||
type: CheckoutSessionResponseDto,
|
||||
})
|
||||
async getSession(@Param() params: CheckoutSessionIdParamDto) {
|
||||
const session = await this.checkoutSessions.getSession(params.sessionId);
|
||||
return checkoutSessionResponseSchema.parse({
|
||||
success: true,
|
||||
return {
|
||||
success: true as const,
|
||||
data: {
|
||||
sessionId: params.sessionId,
|
||||
expiresAt: session.expiresAt,
|
||||
@ -136,12 +123,16 @@ export class CheckoutController {
|
||||
totals: session.cart.totals,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@Post("validate")
|
||||
@UsePipes(new ZodValidationPipe(checkoutCartSchema))
|
||||
validateCart(@Body() cart: CheckoutCart) {
|
||||
@ZodResponse({
|
||||
status: 201,
|
||||
description: "Validate checkout cart",
|
||||
type: ValidateCartResponseDto,
|
||||
})
|
||||
validateCart(@Body() cart: CheckoutCartDto) {
|
||||
this.logger.log("Validating checkout cart", {
|
||||
itemCount: cart.items.length,
|
||||
});
|
||||
@ -149,10 +140,7 @@ export class CheckoutController {
|
||||
try {
|
||||
this.checkoutService.validateCart(cart);
|
||||
|
||||
return validateCartResponseSchema.parse({
|
||||
success: true,
|
||||
data: { valid: true },
|
||||
});
|
||||
return { success: true as const, data: { valid: true } };
|
||||
} catch (error) {
|
||||
this.logger.error("Checkout cart validation failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
Post,
|
||||
Request,
|
||||
Sse,
|
||||
UsePipes,
|
||||
UseGuards,
|
||||
UnauthorizedException,
|
||||
type MessageEvent,
|
||||
@ -16,13 +15,15 @@ import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
|
||||
import { OrderOrchestrator } from "./services/order-orchestrator.service.js";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import {
|
||||
checkoutSessionCreateOrderRequestSchema,
|
||||
createOrderRequestSchema,
|
||||
orderCreateResponseSchema,
|
||||
sfOrderIdParamSchema,
|
||||
orderDetailsSchema,
|
||||
orderListResponseSchema,
|
||||
type CreateOrderRequest,
|
||||
type SfOrderIdParam,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||
import { Observable } from "rxjs";
|
||||
@ -31,11 +32,16 @@ import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards
|
||||
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
||||
import { CheckoutService } from "./services/checkout.service.js";
|
||||
import { CheckoutSessionService } from "./services/checkout-session.service.js";
|
||||
import { z } from "zod";
|
||||
import { SkipSuccessEnvelope } from "@bff/core/http/transform.interceptor.js";
|
||||
|
||||
const checkoutSessionCreateOrderSchema = z.object({
|
||||
checkoutSessionId: z.string().uuid(),
|
||||
});
|
||||
class CreateOrderRequestDto extends createZodDto(createOrderRequestSchema) {}
|
||||
class CheckoutSessionCreateOrderDto extends createZodDto(checkoutSessionCreateOrderRequestSchema) {}
|
||||
class SfOrderIdParamDto extends createZodDto(sfOrderIdParamSchema) {}
|
||||
class CreateOrderResponseDto extends createZodDto(
|
||||
apiSuccessResponseSchema(orderCreateResponseSchema)
|
||||
) {}
|
||||
class OrderDetailsDto extends createZodDto(orderDetailsSchema) {}
|
||||
class OrderListResponseDto extends createZodDto(orderListResponseSchema) {}
|
||||
|
||||
@Controller("orders")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@ -48,13 +54,11 @@ export class OrdersController {
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private readonly createOrderResponseSchema = apiSuccessResponseSchema(orderCreateResponseSchema);
|
||||
|
||||
@Post()
|
||||
@UseGuards(SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute
|
||||
@UsePipes(new ZodValidationPipe(createOrderRequestSchema))
|
||||
async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) {
|
||||
@ZodResponse({ status: 201, description: "Create order", type: CreateOrderResponseDto })
|
||||
async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequestDto) {
|
||||
this.logger.log(
|
||||
{
|
||||
userId: req.user?.id,
|
||||
@ -66,7 +70,7 @@ export class OrdersController {
|
||||
|
||||
try {
|
||||
const result = await this.orderOrchestrator.createOrder(req.user.id, body);
|
||||
return this.createOrderResponseSchema.parse({ success: true, data: result });
|
||||
return { success: true as const, data: result };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{
|
||||
@ -83,10 +87,14 @@ export class OrdersController {
|
||||
@Post("from-checkout-session")
|
||||
@UseGuards(SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute
|
||||
@UsePipes(new ZodValidationPipe(checkoutSessionCreateOrderSchema))
|
||||
@ZodResponse({
|
||||
status: 201,
|
||||
description: "Create order from checkout session",
|
||||
type: CreateOrderResponseDto,
|
||||
})
|
||||
async createFromCheckoutSession(
|
||||
@Request() req: RequestWithUser,
|
||||
@Body() body: { checkoutSessionId: string }
|
||||
@Body() body: CheckoutSessionCreateOrderDto
|
||||
) {
|
||||
this.logger.log(
|
||||
{
|
||||
@ -129,19 +137,20 @@ export class OrdersController {
|
||||
|
||||
await this.checkoutSessions.deleteSession(body.checkoutSessionId);
|
||||
|
||||
return this.createOrderResponseSchema.parse({ success: true, data: result });
|
||||
return { success: true as const, data: result };
|
||||
}
|
||||
|
||||
@Get("user")
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@ZodResponse({ description: "Get user orders", type: OrderListResponseDto })
|
||||
async getUserOrders(@Request() req: RequestWithUser) {
|
||||
return this.orderOrchestrator.getOrdersForUser(req.user.id);
|
||||
}
|
||||
|
||||
@Get(":sfOrderId")
|
||||
@UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
async get(@Request() req: RequestWithUser, @Param() params: SfOrderIdParam) {
|
||||
@ZodResponse({ description: "Get order details", type: OrderDetailsDto })
|
||||
async get(@Request() req: RequestWithUser, @Param() params: SfOrderIdParamDto) {
|
||||
if (!req.user?.id) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
@ -149,11 +158,11 @@ export class OrdersController {
|
||||
}
|
||||
|
||||
@Sse(":sfOrderId/events")
|
||||
@UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@SkipSuccessEnvelope()
|
||||
async streamOrderUpdates(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param() params: SfOrderIdParam
|
||||
@Param() params: SfOrderIdParamDto
|
||||
): Promise<Observable<MessageEvent>> {
|
||||
// Ensure caller is allowed to access this order stream (avoid leaking existence)
|
||||
try {
|
||||
|
||||
@ -9,6 +9,7 @@ import { OrderItemBuilder } from "./order-item-builder.service.js";
|
||||
import type { OrderItemCompositePayload } from "./order-item-builder.service.js";
|
||||
import { OrdersCacheService } from "./orders-cache.service.js";
|
||||
import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import type { CreateOrderRequest } from "@customer-portal/domain/orders";
|
||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
||||
|
||||
@ -35,12 +36,12 @@ export class OrderOrchestrator {
|
||||
/**
|
||||
* Create a new order - main entry point
|
||||
*/
|
||||
async createOrder(userId: string, rawBody: unknown) {
|
||||
async createOrder(userId: string, body: CreateOrderRequest) {
|
||||
this.logger.log({ userId }, "Order creation workflow started");
|
||||
|
||||
// 1) Complete validation (format + business rules)
|
||||
const { validatedBody, userMapping, pricebookId } =
|
||||
await this.orderValidator.validateCompleteOrder(userId, rawBody);
|
||||
await this.orderValidator.validateCompleteOrder(userId, body);
|
||||
|
||||
this.logger.log(
|
||||
{
|
||||
|
||||
@ -5,7 +5,6 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import {
|
||||
createOrderRequestSchema,
|
||||
orderWithSkuValidationSchema,
|
||||
type CreateOrderRequest,
|
||||
type OrderBusinessValidation,
|
||||
@ -38,48 +37,6 @@ export class OrderValidator {
|
||||
private readonly residenceCards: ResidenceCardService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate request format and structure using direct Zod validation
|
||||
*/
|
||||
validateRequestFormat(rawBody: unknown): CreateOrderRequest {
|
||||
try {
|
||||
this.logger.debug({ bodyType: typeof rawBody }, "Starting request format validation");
|
||||
|
||||
// Use direct Zod validation with .parse() - throws ZodError on failure
|
||||
const validatedBody = createOrderRequestSchema.parse(rawBody);
|
||||
|
||||
this.logger.debug(
|
||||
{
|
||||
orderType: validatedBody.orderType,
|
||||
skuCount: validatedBody.skus.length,
|
||||
hasConfigurations: !!validatedBody.configurations,
|
||||
},
|
||||
"Request format validation passed"
|
||||
);
|
||||
|
||||
return validatedBody;
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const errorMessages = error.issues.map(issue => {
|
||||
const path = issue.path.join(".");
|
||||
return path ? `${path}: ${issue.message}` : issue.message;
|
||||
});
|
||||
|
||||
this.logger.error({ errors: errorMessages }, "Zod validation failed");
|
||||
|
||||
throw new BadRequestException({
|
||||
message: "Order validation failed",
|
||||
errors: errorMessages,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error({ error: errorMessage }, "Request format validation failed");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user mapping exists - simple business logic
|
||||
*/
|
||||
@ -222,7 +179,7 @@ export class OrderValidator {
|
||||
*/
|
||||
async validateCompleteOrder(
|
||||
userId: string,
|
||||
rawBody: unknown
|
||||
body: CreateOrderRequest
|
||||
): Promise<{
|
||||
validatedBody: OrderBusinessValidation;
|
||||
userMapping: { userId: string; sfAccountId?: string; whmcsClientId: number };
|
||||
@ -230,8 +187,8 @@ export class OrderValidator {
|
||||
}> {
|
||||
this.logger.log({ userId }, "Starting complete order validation");
|
||||
|
||||
// 1. Format validation (replaces DTO validation)
|
||||
const validatedBody = this.validateRequestFormat(rawBody);
|
||||
// 1. Format validation is performed in the controller layer via Zod DTO + global pipe.
|
||||
const validatedBody = body;
|
||||
|
||||
// 1b. Business validation (ensures userId-specific constraints)
|
||||
let businessValidatedBody: OrderBusinessValidation;
|
||||
|
||||
@ -18,6 +18,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||
import { RealtimeConnectionLimiterService } from "./realtime-connection-limiter.service.js";
|
||||
import { SkipSuccessEnvelope } from "@bff/core/http/transform.interceptor.js";
|
||||
|
||||
@Controller("events")
|
||||
export class RealtimeController {
|
||||
@ -39,6 +40,7 @@ export class RealtimeController {
|
||||
@RateLimit({ limit: 30, ttl: 60 }) // protect against reconnect storms / refresh spam
|
||||
@Header("Cache-Control", "private, no-store")
|
||||
@Header("X-Accel-Buffering", "no") // nginx: disable response buffering for SSE
|
||||
@SkipSuccessEnvelope()
|
||||
async stream(@Request() req: RequestWithUser): Promise<Observable<MessageEvent>> {
|
||||
if (!this.limiter.tryAcquire(req.user.id)) {
|
||||
throw new HttpException(
|
||||
|
||||
@ -1,18 +1,12 @@
|
||||
import { Body, Controller, Get, Header, Post, Req, UseGuards, UsePipes } from "@nestjs/common";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { z } from "zod";
|
||||
import { Body, Controller, Get, Header, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import { createZodDto } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||
import { InternetServicesService } from "./services/internet-services.service.js";
|
||||
import { addressSchema } from "@customer-portal/domain/customer";
|
||||
import type { InternetEligibilityDetails } from "@customer-portal/domain/services";
|
||||
import { internetEligibilityRequestSchema } from "@customer-portal/domain/services";
|
||||
|
||||
const eligibilityRequestSchema = z.object({
|
||||
notes: z.string().trim().max(2000).optional(),
|
||||
address: addressSchema.partial().optional(),
|
||||
});
|
||||
|
||||
type EligibilityRequest = z.infer<typeof eligibilityRequestSchema>;
|
||||
class EligibilityRequestDto extends createZodDto(internetEligibilityRequestSchema) {}
|
||||
|
||||
/**
|
||||
* Internet Eligibility Controller
|
||||
@ -38,11 +32,10 @@ export class InternetEligibilityController {
|
||||
|
||||
@Post("eligibility-request")
|
||||
@RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP
|
||||
@UsePipes(new ZodValidationPipe(eligibilityRequestSchema))
|
||||
@Header("Cache-Control", "private, no-store")
|
||||
async requestEligibility(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() body: EligibilityRequest
|
||||
@Body() body: EligibilityRequestDto
|
||||
): Promise<{ requestId: string }> {
|
||||
const requestId = await this.internetCatalog.requestEligibilityCheckForUser(req.user.id, {
|
||||
email: req.user.email,
|
||||
|
||||
@ -1,21 +1,19 @@
|
||||
import { Body, Controller, Post, Request, UsePipes, Headers } from "@nestjs/common";
|
||||
import { Body, Controller, Post, Request, Headers } from "@nestjs/common";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SimOrderActivationService } from "./sim-order-activation.service.js";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import {
|
||||
simOrderActivationRequestSchema,
|
||||
type SimOrderActivationRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { createZodDto } from "nestjs-zod";
|
||||
import { simOrderActivationRequestSchema } from "@customer-portal/domain/sim";
|
||||
|
||||
class SimOrderActivationRequestDto extends createZodDto(simOrderActivationRequestSchema) {}
|
||||
|
||||
@Controller("subscriptions/sim/orders")
|
||||
export class SimOrdersController {
|
||||
constructor(private readonly activation: SimOrderActivationService) {}
|
||||
|
||||
@Post("activate")
|
||||
@UsePipes(new ZodValidationPipe(simOrderActivationRequestSchema))
|
||||
async activate(
|
||||
@Request() req: RequestWithUser,
|
||||
@Body() body: SimOrderActivationRequest,
|
||||
@Body() body: SimOrderActivationRequestDto,
|
||||
@Headers("x-idempotency-key") idempotencyKey?: string
|
||||
) {
|
||||
const result = await this.activation.activate(req.user.id, body, idempotencyKey);
|
||||
|
||||
@ -7,8 +7,6 @@ import {
|
||||
Body,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
BadRequestException,
|
||||
UsePipes,
|
||||
Header,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
@ -17,41 +15,51 @@ import { SubscriptionsService } from "./subscriptions.service.js";
|
||||
import { SimManagementService } from "./sim-management.service.js";
|
||||
import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.service.js";
|
||||
|
||||
import { subscriptionQuerySchema } from "@customer-portal/domain/subscriptions";
|
||||
import {
|
||||
subscriptionQuerySchema,
|
||||
subscriptionListSchema,
|
||||
subscriptionSchema,
|
||||
subscriptionStatsSchema,
|
||||
simActionResponseSchema,
|
||||
simPlanChangeResultSchema,
|
||||
internetCancellationPreviewSchema,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type {
|
||||
Subscription,
|
||||
SubscriptionList,
|
||||
SubscriptionStats,
|
||||
SimActionResponse,
|
||||
SimPlanChangeResult,
|
||||
SubscriptionQuery,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type { InvoiceList } from "@customer-portal/domain/billing";
|
||||
import type { ApiSuccessResponse } from "@customer-portal/domain/common";
|
||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||
import { createPaginationSchema } from "@customer-portal/domain/toolkit/validation/helpers";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
simTopupRequestSchema,
|
||||
simChangePlanRequestSchema,
|
||||
simCancelRequestSchema,
|
||||
simFeaturesRequestSchema,
|
||||
simTopUpHistoryRequestSchema,
|
||||
simCancelFullRequestSchema,
|
||||
simChangePlanFullRequestSchema,
|
||||
simReissueFullRequestSchema,
|
||||
type SimTopupRequest,
|
||||
type SimChangePlanRequest,
|
||||
type SimCancelRequest,
|
||||
type SimFeaturesRequest,
|
||||
type SimCancelFullRequest,
|
||||
type SimChangePlanFullRequest,
|
||||
simHistoryQuerySchema,
|
||||
simSftpListQuerySchema,
|
||||
simCallHistoryImportQuerySchema,
|
||||
simTopUpPricingPreviewRequestSchema,
|
||||
simReissueEsimRequestSchema,
|
||||
simInfoSchema,
|
||||
simDetailsSchema,
|
||||
simUsageSchema,
|
||||
simTopUpHistorySchema,
|
||||
type SimAvailablePlan,
|
||||
type SimCancellationPreview,
|
||||
type SimDomesticCallHistoryResponse,
|
||||
type SimInternationalCallHistoryResponse,
|
||||
type SimSmsHistoryResponse,
|
||||
type SimReissueFullRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SimPlanService } from "./sim-management/services/sim-plan.service.js";
|
||||
import { SimCancellationService } from "./sim-management/services/sim-cancellation.service.js";
|
||||
@ -61,16 +69,48 @@ import { SimCallHistoryService } from "./sim-management/services/sim-call-histor
|
||||
import { InternetCancellationService } from "./internet-management/services/internet-cancellation.service.js";
|
||||
import {
|
||||
internetCancelRequestSchema,
|
||||
type InternetCancelRequest,
|
||||
type SimActionResponse as SubscriptionActionResponse,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import { invoiceListSchema } from "@customer-portal/domain/billing";
|
||||
|
||||
const subscriptionInvoiceQuerySchema = createPaginationSchema({
|
||||
defaultLimit: 10,
|
||||
maxLimit: 100,
|
||||
minLimit: 1,
|
||||
});
|
||||
type SubscriptionInvoiceQuery = z.infer<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")
|
||||
export class SubscriptionsController {
|
||||
@ -87,10 +127,10 @@ export class SubscriptionsController {
|
||||
|
||||
@Get()
|
||||
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||
@UsePipes(new ZodValidationPipe(subscriptionQuerySchema))
|
||||
@ZodResponse({ description: "List subscriptions", type: SubscriptionListDto })
|
||||
async getSubscriptions(
|
||||
@Request() req: RequestWithUser,
|
||||
@Query() query: SubscriptionQuery
|
||||
@Query() query: SubscriptionQueryDto
|
||||
): Promise<SubscriptionList> {
|
||||
const { status } = query;
|
||||
return this.subscriptionsService.getSubscriptions(req.user.id, { status });
|
||||
@ -98,12 +138,14 @@ export class SubscriptionsController {
|
||||
|
||||
@Get("active")
|
||||
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||
@ZodResponse({ description: "List active subscriptions", type: [SubscriptionDto] })
|
||||
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
|
||||
return this.subscriptionsService.getActiveSubscriptions(req.user.id);
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||
@ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto })
|
||||
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<SubscriptionStats> {
|
||||
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
||||
}
|
||||
@ -126,12 +168,10 @@ export class SubscriptionsController {
|
||||
*/
|
||||
@UseGuards(AdminGuard)
|
||||
@Get("sim/call-history/sftp-files")
|
||||
async listSftpFiles(@Query("path") path: string = "/home/PASI") {
|
||||
if (!path.startsWith("/home/PASI")) {
|
||||
throw new BadRequestException("Invalid path");
|
||||
}
|
||||
const files = await this.simCallHistoryService.listSftpFiles(path);
|
||||
return { success: true, data: files, path };
|
||||
async listSftpFiles(@Query() query: SimSftpListQueryDto) {
|
||||
const parsedQuery = simSftpListQuerySchema.parse(query as unknown);
|
||||
const files = await this.simCallHistoryService.listSftpFiles(parsedQuery.path);
|
||||
return { success: true, data: files, path: parsedQuery.path };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -139,12 +179,9 @@ export class SubscriptionsController {
|
||||
*/
|
||||
@UseGuards(AdminGuard)
|
||||
@Post("sim/call-history/import")
|
||||
async importCallHistory(@Query("month") yearMonth: string) {
|
||||
if (!yearMonth || !/^\d{6}$/.test(yearMonth)) {
|
||||
throw new BadRequestException("Invalid month format (expected YYYYMM)");
|
||||
}
|
||||
|
||||
const result = await this.simCallHistoryService.importCallHistory(yearMonth);
|
||||
async importCallHistory(@Query() query: SimCallHistoryImportQueryDto) {
|
||||
const parsedQuery = simCallHistoryImportQuerySchema.parse(query as unknown);
|
||||
const result = await this.simCallHistoryService.importCallHistory(parsedQuery.month);
|
||||
return {
|
||||
success: true,
|
||||
message: `Imported ${result.domestic} domestic calls, ${result.international} international calls, ${result.sms} SMS`,
|
||||
@ -161,13 +198,8 @@ export class SubscriptionsController {
|
||||
|
||||
@Get("sim/top-up/pricing/preview")
|
||||
@Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing calculation is deterministic
|
||||
async previewSimTopUpPricing(@Query("quotaMb") quotaMb: string) {
|
||||
const quotaMbNum = parseInt(quotaMb, 10);
|
||||
if (isNaN(quotaMbNum) || quotaMbNum <= 0) {
|
||||
throw new BadRequestException("Invalid quotaMb parameter");
|
||||
}
|
||||
|
||||
const preview = await this.simTopUpPricingService.calculatePricingPreview(quotaMbNum);
|
||||
async previewSimTopUpPricing(@Query() query: SimTopUpPricingPreviewRequestDto) {
|
||||
const preview = await this.simTopUpPricingService.calculatePricingPreview(query.quotaMb);
|
||||
return { success: true, data: preview };
|
||||
}
|
||||
|
||||
@ -181,6 +213,7 @@ export class SubscriptionsController {
|
||||
|
||||
@Get(":id")
|
||||
@Header("Cache-Control", "private, max-age=300") // 5 minutes, user-specific
|
||||
@ZodResponse({ description: "Get subscription", type: SubscriptionDto })
|
||||
async getSubscriptionById(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
@ -190,10 +223,11 @@ export class SubscriptionsController {
|
||||
|
||||
@Get(":id/invoices")
|
||||
@Header("Cache-Control", "private, max-age=60") // 1 minute, may update with payments
|
||||
@ZodResponse({ description: "Get subscription invoices", type: InvoiceListDto })
|
||||
async getSubscriptionInvoices(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query(new ZodValidationPipe(subscriptionInvoiceQuerySchema)) query: SubscriptionInvoiceQuery
|
||||
@Query() query: SubscriptionInvoiceQueryDto
|
||||
): Promise<InvoiceList> {
|
||||
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, query);
|
||||
}
|
||||
@ -210,6 +244,7 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Get(":id/sim")
|
||||
@ZodResponse({ description: "Get SIM info", type: SimInfoDto })
|
||||
async getSimInfo(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
@ -218,6 +253,7 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Get(":id/sim/details")
|
||||
@ZodResponse({ description: "Get SIM details", type: SimDetailsDto })
|
||||
async getSimDetails(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
@ -226,6 +262,7 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Get(":id/sim/usage")
|
||||
@ZodResponse({ description: "Get SIM usage", type: SimUsageDto })
|
||||
async getSimUsage(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
@ -234,39 +271,32 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Get(":id/sim/top-up-history")
|
||||
@ZodResponse({ description: "Get SIM top-up history", type: SimTopUpHistoryDto })
|
||||
async getSimTopUpHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("fromDate") fromDate: string,
|
||||
@Query("toDate") toDate: string
|
||||
@Query() query: SimTopUpHistoryRequestDto
|
||||
) {
|
||||
if (!fromDate || !toDate) {
|
||||
throw new BadRequestException("fromDate and toDate are required");
|
||||
}
|
||||
|
||||
return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, {
|
||||
fromDate,
|
||||
toDate,
|
||||
});
|
||||
return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, query);
|
||||
}
|
||||
|
||||
@Post(":id/sim/top-up")
|
||||
@UsePipes(new ZodValidationPipe(simTopupRequestSchema))
|
||||
@ZodResponse({ description: "Top up SIM", type: SimActionResponseDto })
|
||||
async topUpSim(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimTopupRequest
|
||||
@Body() body: SimTopupRequestDto
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
|
||||
return { success: true, message: "SIM top-up completed successfully" };
|
||||
}
|
||||
|
||||
@Post(":id/sim/change-plan")
|
||||
@UsePipes(new ZodValidationPipe(simChangePlanRequestSchema))
|
||||
@ZodResponse({ description: "Change SIM plan", type: SimPlanChangeResultDto })
|
||||
async changeSimPlan(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimChangePlanRequest
|
||||
@Body() body: SimChangePlanRequestDto
|
||||
): Promise<SimPlanChangeResult> {
|
||||
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
@ -277,11 +307,11 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Post(":id/sim/cancel")
|
||||
@UsePipes(new ZodValidationPipe(simCancelRequestSchema))
|
||||
@ZodResponse({ description: "Cancel SIM", type: SimActionResponseDto })
|
||||
async cancelSim(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimCancelRequest
|
||||
@Body() body: SimCancelRequestDto
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simManagementService.cancelSim(req.user.id, subscriptionId, body);
|
||||
return { success: true, message: "SIM cancellation completed successfully" };
|
||||
@ -291,18 +321,23 @@ export class SubscriptionsController {
|
||||
async reissueEsimProfile(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: { newEid?: string } = {}
|
||||
@Body() body: SimReissueEsimRequestDto
|
||||
): Promise<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" };
|
||||
}
|
||||
|
||||
@Post(":id/sim/features")
|
||||
@UsePipes(new ZodValidationPipe(simFeaturesRequestSchema))
|
||||
@ZodResponse({ description: "Update SIM features", type: SimActionResponseDto })
|
||||
async updateSimFeatures(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimFeaturesRequest
|
||||
@Body() body: SimFeaturesRequestDto
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
||||
return { success: true, message: "SIM features updated successfully" };
|
||||
@ -327,11 +362,11 @@ export class SubscriptionsController {
|
||||
* Change SIM plan with enhanced flow (Salesforce SKU mapping + email notifications)
|
||||
*/
|
||||
@Post(":id/sim/change-plan-full")
|
||||
@UsePipes(new ZodValidationPipe(simChangePlanFullRequestSchema))
|
||||
@ZodResponse({ description: "Change SIM plan (full)", type: SimPlanChangeResultDto })
|
||||
async changeSimPlanFull(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimChangePlanFullRequest
|
||||
@Body() body: SimChangePlanFullRequestDto
|
||||
): Promise<SimPlanChangeResult> {
|
||||
const result = await this.simPlanService.changeSimPlanFull(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
@ -361,11 +396,11 @@ export class SubscriptionsController {
|
||||
* Cancel SIM with full flow (PA02-04 + email notifications)
|
||||
*/
|
||||
@Post(":id/sim/cancel-full")
|
||||
@UsePipes(new ZodValidationPipe(simCancelFullRequestSchema))
|
||||
@ZodResponse({ description: "Cancel SIM (full)", type: SimActionResponseDto })
|
||||
async cancelSimFull(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimCancelFullRequest
|
||||
@Body() body: SimCancelFullRequestDto
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simCancellationService.cancelSimFull(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
@ -378,11 +413,11 @@ export class SubscriptionsController {
|
||||
* Reissue SIM (both eSIM and physical SIM)
|
||||
*/
|
||||
@Post(":id/sim/reissue")
|
||||
@UsePipes(new ZodValidationPipe(simReissueFullRequestSchema))
|
||||
@ZodResponse({ description: "Reissue SIM", type: SimActionResponseDto })
|
||||
async reissueSim(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimReissueFullRequest
|
||||
@Body() body: SimReissueFullRequestDto
|
||||
): Promise<SimActionResponse> {
|
||||
await this.esimManagementService.reissueSim(req.user.id, subscriptionId, body);
|
||||
|
||||
@ -403,6 +438,10 @@ export class SubscriptionsController {
|
||||
*/
|
||||
@Get(":id/internet/cancellation-preview")
|
||||
@Header("Cache-Control", "private, max-age=60")
|
||||
@ZodResponse({
|
||||
description: "Get internet cancellation preview",
|
||||
type: InternetCancellationPreviewResponseDto,
|
||||
})
|
||||
async getInternetCancellationPreview(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
@ -411,18 +450,18 @@ export class SubscriptionsController {
|
||||
req.user.id,
|
||||
subscriptionId
|
||||
);
|
||||
return { success: true, data: preview };
|
||||
return { success: true as const, data: preview };
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit Internet cancellation request
|
||||
*/
|
||||
@Post(":id/internet/cancel")
|
||||
@UsePipes(new ZodValidationPipe(internetCancelRequestSchema))
|
||||
@ZodResponse({ description: "Cancel internet", type: SimActionResponseDto })
|
||||
async cancelInternet(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: InternetCancelRequest
|
||||
@Body() body: InternetCancelRequestDto
|
||||
): Promise<SubscriptionActionResponse> {
|
||||
await this.internetCancellationService.submitCancellation(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
@ -441,26 +480,15 @@ export class SubscriptionsController {
|
||||
async getDomesticCallHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("month") month?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
@Query() query: SimHistoryQueryDto
|
||||
): Promise<ApiSuccessResponse<SimDomesticCallHistoryResponse>> {
|
||||
const pageNum = parseInt(page || "1", 10);
|
||||
const limitNum = parseInt(limit || "50", 10);
|
||||
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
throw new BadRequestException("Invalid page number");
|
||||
}
|
||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||
throw new BadRequestException("Invalid limit (must be 1-100)");
|
||||
}
|
||||
|
||||
const parsedQuery = simHistoryQuerySchema.parse(query as unknown);
|
||||
const result = await this.simCallHistoryService.getDomesticCallHistory(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
month,
|
||||
pageNum,
|
||||
limitNum
|
||||
parsedQuery.month,
|
||||
parsedQuery.page,
|
||||
parsedQuery.limit
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
@ -473,26 +501,15 @@ export class SubscriptionsController {
|
||||
async getInternationalCallHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("month") month?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
@Query() query: SimHistoryQueryDto
|
||||
): Promise<ApiSuccessResponse<SimInternationalCallHistoryResponse>> {
|
||||
const pageNum = parseInt(page || "1", 10);
|
||||
const limitNum = parseInt(limit || "50", 10);
|
||||
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
throw new BadRequestException("Invalid page number");
|
||||
}
|
||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||
throw new BadRequestException("Invalid limit (must be 1-100)");
|
||||
}
|
||||
|
||||
const parsedQuery = simHistoryQuerySchema.parse(query as unknown);
|
||||
const result = await this.simCallHistoryService.getInternationalCallHistory(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
month,
|
||||
pageNum,
|
||||
limitNum
|
||||
parsedQuery.month,
|
||||
parsedQuery.page,
|
||||
parsedQuery.limit
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
@ -505,26 +522,15 @@ export class SubscriptionsController {
|
||||
async getSmsHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("month") month?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
@Query() query: SimHistoryQueryDto
|
||||
): Promise<ApiSuccessResponse<SimSmsHistoryResponse>> {
|
||||
const pageNum = parseInt(page || "1", 10);
|
||||
const limitNum = parseInt(limit || "50", 10);
|
||||
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
throw new BadRequestException("Invalid page number");
|
||||
}
|
||||
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||
throw new BadRequestException("Invalid limit (must be 1-100)");
|
||||
}
|
||||
|
||||
const parsedQuery = simHistoryQuerySchema.parse(query as unknown);
|
||||
const result = await this.simCallHistoryService.getSmsHistory(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
month,
|
||||
pageNum,
|
||||
limitNum
|
||||
parsedQuery.month,
|
||||
parsedQuery.page,
|
||||
parsedQuery.limit
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@ -11,23 +11,32 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SupportService } from "./support.service.js";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||
import {
|
||||
supportCaseFilterSchema,
|
||||
supportCaseListSchema,
|
||||
supportCaseSchema,
|
||||
createCaseRequestSchema,
|
||||
createCaseResponseSchema,
|
||||
publicContactRequestSchema,
|
||||
type SupportCaseFilter,
|
||||
type SupportCaseList,
|
||||
type SupportCase,
|
||||
type CreateCaseRequest,
|
||||
type CreateCaseResponse,
|
||||
type PublicContactRequest,
|
||||
} from "@customer-portal/domain/support";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { hashEmailForLogs } from "./support.logging.js";
|
||||
import type { ApiSuccessMessageResponse } from "@customer-portal/domain/common";
|
||||
import { apiSuccessMessageResponseSchema } from "@customer-portal/domain/common";
|
||||
|
||||
class SupportCaseFilterDto extends createZodDto(supportCaseFilterSchema) {}
|
||||
class CreateCaseRequestDto extends createZodDto(createCaseRequestSchema) {}
|
||||
class PublicContactRequestDto extends createZodDto(publicContactRequestSchema) {}
|
||||
class SupportCaseListDto extends createZodDto(supportCaseListSchema) {}
|
||||
class SupportCaseDto extends createZodDto(supportCaseSchema) {}
|
||||
class CreateCaseResponseDto extends createZodDto(createCaseResponseSchema) {}
|
||||
class ApiSuccessMessageResponseDto extends createZodDto(apiSuccessMessageResponseSchema) {}
|
||||
|
||||
@Controller("support")
|
||||
export class SupportController {
|
||||
@ -37,15 +46,16 @@ export class SupportController {
|
||||
) {}
|
||||
|
||||
@Get("cases")
|
||||
@ZodResponse({ description: "List support cases", type: SupportCaseListDto })
|
||||
async listCases(
|
||||
@Request() req: RequestWithUser,
|
||||
@Query(new ZodValidationPipe(supportCaseFilterSchema))
|
||||
filters: SupportCaseFilter
|
||||
@Query() filters: SupportCaseFilterDto
|
||||
): Promise<SupportCaseList> {
|
||||
return this.supportService.listCases(req.user.id, filters);
|
||||
}
|
||||
|
||||
@Get("cases/:id")
|
||||
@ZodResponse({ description: "Get support case", type: SupportCaseDto })
|
||||
async getCase(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id") caseId: string
|
||||
@ -54,10 +64,10 @@ export class SupportController {
|
||||
}
|
||||
|
||||
@Post("cases")
|
||||
@ZodResponse({ description: "Create support case", type: CreateCaseResponseDto })
|
||||
async createCase(
|
||||
@Request() req: RequestWithUser,
|
||||
@Body(new ZodValidationPipe(createCaseRequestSchema))
|
||||
body: CreateCaseRequest
|
||||
@Body() body: CreateCaseRequestDto
|
||||
): Promise<CreateCaseResponse> {
|
||||
return this.supportService.createCase(req.user.id, body);
|
||||
}
|
||||
@ -72,17 +82,18 @@ export class SupportController {
|
||||
@Public()
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes
|
||||
async publicContact(
|
||||
@Body(new ZodValidationPipe(publicContactRequestSchema))
|
||||
body: PublicContactRequest
|
||||
): Promise<ApiSuccessMessageResponse> {
|
||||
@ZodResponse({
|
||||
description: "Public contact form submission",
|
||||
type: ApiSuccessMessageResponseDto,
|
||||
})
|
||||
async publicContact(@Body() body: PublicContactRequestDto): Promise<ApiSuccessMessageResponse> {
|
||||
this.logger.log("Public contact form submission", { emailHash: hashEmailForLogs(body.email) });
|
||||
|
||||
try {
|
||||
await this.supportService.createPublicContactRequest(body);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
success: true as const,
|
||||
message: "Your message has been received. We will get back to you within 24 hours.",
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@ -6,20 +6,24 @@ import {
|
||||
Req,
|
||||
UseInterceptors,
|
||||
ClassSerializerInterceptor,
|
||||
UsePipes,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { UsersFacade } from "./application/users.facade.js";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import {
|
||||
updateCustomerProfileRequestSchema,
|
||||
type UpdateCustomerProfileRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { addressSchema } from "@customer-portal/domain/customer";
|
||||
import { createZodDto, ZodResponse, ZodSerializerDto } from "nestjs-zod";
|
||||
import { updateCustomerProfileRequestSchema } from "@customer-portal/domain/auth";
|
||||
import { dashboardSummarySchema } from "@customer-portal/domain/dashboard";
|
||||
import { addressSchema, userSchema } from "@customer-portal/domain/customer";
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
|
||||
class UpdateAddressDto extends createZodDto(addressSchema.partial()) {}
|
||||
class UpdateCustomerProfileRequestDto extends createZodDto(updateCustomerProfileRequestSchema) {}
|
||||
class AddressDto extends createZodDto(addressSchema) {}
|
||||
class UserDto extends createZodDto(userSchema) {}
|
||||
class DashboardSummaryDto extends createZodDto(dashboardSummarySchema) {}
|
||||
|
||||
@Controller("me")
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
export class UsersController {
|
||||
@ -31,8 +35,10 @@ export class UsersController {
|
||||
*/
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@Get()
|
||||
async getProfile(@Req() req: RequestWithUser) {
|
||||
return this.usersFacade.findById(req.user.id);
|
||||
@ZodResponse({ description: "Get user profile", type: UserDto })
|
||||
async getProfile(@Req() req: RequestWithUser): Promise<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)
|
||||
@Get("summary")
|
||||
@ZodResponse({ description: "Get dashboard summary", type: DashboardSummaryDto })
|
||||
async getSummary(@Req() req: RequestWithUser) {
|
||||
return this.usersFacade.getUserSummary(req.user.id);
|
||||
}
|
||||
@ -49,6 +56,7 @@ export class UsersController {
|
||||
*/
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@Get("address")
|
||||
@ZodSerializerDto(addressSchema.nullable())
|
||||
async getAddress(@Req() req: RequestWithUser): Promise<Address | null> {
|
||||
return this.usersFacade.getAddress(req.user.id);
|
||||
}
|
||||
@ -57,10 +65,10 @@ export class UsersController {
|
||||
* PATCH /me/address - Update address fields
|
||||
*/
|
||||
@Patch("address")
|
||||
@UsePipes(new ZodValidationPipe(addressSchema.partial()))
|
||||
@ZodResponse({ description: "Update address", type: AddressDto })
|
||||
async updateAddress(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() address: Partial<Address>
|
||||
@Body() address: UpdateAddressDto
|
||||
): Promise<Address> {
|
||||
return this.usersFacade.updateAddress(req.user.id, address);
|
||||
}
|
||||
@ -76,10 +84,10 @@ export class UsersController {
|
||||
* - Update both: { firstname: "John", address1: "123 Main St" }
|
||||
*/
|
||||
@Patch()
|
||||
@UsePipes(new ZodValidationPipe(updateCustomerProfileRequestSchema))
|
||||
@ZodResponse({ description: "Update profile", type: UserDto })
|
||||
async updateProfile(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() updateData: UpdateCustomerProfileRequest
|
||||
@Body() updateData: UpdateCustomerProfileRequestDto
|
||||
) {
|
||||
return this.usersFacade.updateProfile(req.user.id, updateData);
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ export * from "./response-helpers";
|
||||
|
||||
// Import createClient for internal use
|
||||
import { createClient, ApiError } from "./runtime/client";
|
||||
import { parseDomainError } from "./response-helpers";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
/**
|
||||
@ -45,6 +46,11 @@ function isAuthEndpoint(url: string): boolean {
|
||||
* Handles both `{ message }` and `{ error: { message } }` formats
|
||||
*/
|
||||
function extractErrorMessage(body: unknown): string | null {
|
||||
const domainError = parseDomainError(body);
|
||||
if (domainError) {
|
||||
return domainError.error.message;
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ApiResponse } from "../response-helpers";
|
||||
import { parseDomainError, type ApiResponse } from "../response-helpers";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export class ApiError extends Error {
|
||||
@ -132,6 +132,11 @@ const getBodyMessage = (body: unknown): string | null => {
|
||||
return body;
|
||||
}
|
||||
|
||||
const domainError = parseDomainError(body);
|
||||
if (domainError) {
|
||||
return domainError.error.message;
|
||||
}
|
||||
|
||||
if (body && typeof body === "object" && "message" in body) {
|
||||
const maybeMessage = (body as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === "string") {
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
ErrorMetadata,
|
||||
type ErrorCodeType,
|
||||
} from "@customer-portal/domain/common";
|
||||
import { parseDomainError } from "@/lib/api";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@ -69,29 +70,20 @@ function parseApiError(error: ClientApiError): ParsedError {
|
||||
const body = error.body;
|
||||
const status = error.response?.status;
|
||||
|
||||
// Try to extract from standard API error response format
|
||||
if (body && typeof body === "object") {
|
||||
const bodyObj = body as Record<string, unknown>;
|
||||
const domainError = parseDomainError(body);
|
||||
if (domainError) {
|
||||
const rawCode = domainError.error.code;
|
||||
const resolvedCode: ErrorCodeType = Object.prototype.hasOwnProperty.call(ErrorMetadata, rawCode)
|
||||
? (rawCode as ErrorCodeType)
|
||||
: ErrorCode.UNKNOWN;
|
||||
|
||||
// Check for standard { success: false, error: { code, message } } format
|
||||
if (bodyObj.success === false && bodyObj.error && typeof bodyObj.error === "object") {
|
||||
const errorObj = bodyObj.error as Record<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 {
|
||||
code: code as ErrorCodeType,
|
||||
message,
|
||||
shouldLogout: metadata.shouldLogout,
|
||||
shouldRetry: metadata.shouldRetry,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No message-based code inference. If the response doesn't include a structured error code,
|
||||
// we fall back to status-based mapping below.
|
||||
const metadata = ErrorMetadata[resolvedCode] ?? ErrorMetadata[ErrorCode.UNKNOWN];
|
||||
return {
|
||||
code: resolvedCode,
|
||||
message: domainError.error.message,
|
||||
shouldLogout: metadata.shouldLogout,
|
||||
shouldRetry: metadata.shouldRetry,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to status code mapping
|
||||
|
||||
@ -101,11 +101,12 @@ Feature guides explaining how the portal functions:
|
||||
|
||||
### BFF (Backend for Frontend)
|
||||
|
||||
| Document | Description |
|
||||
| ----------------------------------------------------------------- | --------------------------- |
|
||||
| [Integration Patterns](./development/bff/integration-patterns.md) | Clean architecture patterns |
|
||||
| [DB Mappers](./development/bff/db-mappers.md) | Database mapping |
|
||||
| [Order Status Updates](./development/bff/order-status-updates.md) | Status update strategy |
|
||||
| Document | Description |
|
||||
| ----------------------------------------------------------------- | ---------------------------- |
|
||||
| [Integration Patterns](./development/bff/integration-patterns.md) | Clean architecture patterns |
|
||||
| [Validation Standard](./development/bff/validation.md) | DTO validation + global pipe |
|
||||
| [DB Mappers](./development/bff/db-mappers.md) | Database mapping |
|
||||
| [Order Status Updates](./development/bff/order-status-updates.md) | Status update strategy |
|
||||
|
||||
### Portal (Frontend)
|
||||
|
||||
|
||||
@ -40,9 +40,9 @@ BFF (NestJS)
|
||||
|
||||
## Validation Workflow (Zod-First)
|
||||
|
||||
- Shared schemas live in `packages/domain/src/validation`.
|
||||
- Backend registers `nestjs-zod`'s `ZodValidationPipe` globally via `APP_PIPE`.
|
||||
- Controllers compose schemas by importing contracts from the shared package. For query params and body validation, use `@UsePipes(new ZodValidationPipe(schema))`.
|
||||
- Shared schemas live in `packages/domain/<domain>/schema.ts` (and helpers in `packages/domain/toolkit/validation/`).
|
||||
- BFF registers `nestjs-zod`'s `ZodValidationPipe` globally via `APP_PIPE` (see `apps/bff/src/app.module.ts`).
|
||||
- Controllers define DTOs via `createZodDto(schema)` and rely on the global pipe for `@Body()`, `@Param()`, and `@Query()` validation (avoid per-route pipes unless you need an override).
|
||||
- Services call `schema.parse` when mapping external data (Salesforce, WHMCS) to ensure the response matches the contract.
|
||||
- Frontend imports the same schemas/types (and `useZodForm` helpers) to keep UI validation in sync with backend rules.
|
||||
- Error handling runs through custom filters; if custom formatting is needed for Zod errors, catch `ZodValidationException`.
|
||||
|
||||
61
docs/development/bff/validation.md
Normal file
61
docs/development/bff/validation.md
Normal 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 module’s `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)
|
||||
@ -235,16 +235,16 @@ Ask these questions:
|
||||
|
||||
## 📋 Examples with Decisions
|
||||
|
||||
| Utility | Decision | Location | Why |
|
||||
| ---------------------------------- | --------------------- | --------------------------------- | -------------------------- |
|
||||
| `formatCurrency(amount, currency)` | ✅ Domain | `domain/toolkit/formatting/` | Pure function, no deps |
|
||||
| `invoiceQueryParamsSchema` | ✅ Domain | `domain/billing/schema.ts` | Domain-specific validation |
|
||||
| `paginationParamsSchema` | ✅ Domain | `domain/common/schema.ts` | Truly generic |
|
||||
| `useInvoices()` React hook | ❌ Portal App | `portal/lib/hooks/` | React-specific |
|
||||
| `apiClient` instance | ❌ Portal App | `portal/lib/api/` | Framework-specific |
|
||||
| `ZodValidationPipe` | ✅ Validation Package | `packages/validation/` | Reusable bridge |
|
||||
| `getErrorMessage(error)` | ❌ BFF App | `bff/core/utils/` | App-specific utility |
|
||||
| `transformWhmcsInvoice()` | ✅ Domain | `domain/billing/providers/whmcs/` | Data transformation |
|
||||
| Utility | Decision | Location | Why |
|
||||
| ---------------------------------- | ------------- | --------------------------------- | -------------------------- |
|
||||
| `formatCurrency(amount, currency)` | ✅ Domain | `domain/toolkit/formatting/` | Pure function, no deps |
|
||||
| `invoiceQueryParamsSchema` | ✅ Domain | `domain/billing/schema.ts` | Domain-specific validation |
|
||||
| `paginationParamsSchema` | ✅ Domain | `domain/common/schema.ts` | Truly generic |
|
||||
| `useInvoices()` React hook | ❌ Portal App | `portal/lib/hooks/` | React-specific |
|
||||
| `apiClient` instance | ❌ Portal App | `portal/lib/api/` | Framework-specific |
|
||||
| `ZodValidationPipe` | ❌ BFF App | `apps/bff/src/app.module.ts` | NestJS/framework-specific |
|
||||
| `getErrorMessage(error)` | ❌ BFF App | `bff/core/utils/` | App-specific utility |
|
||||
| `transformWhmcsInvoice()` | ✅ Domain | `domain/billing/providers/whmcs/` | Data transformation |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
// =============================================================================
|
||||
|
||||
@ -25,6 +25,8 @@ export type {
|
||||
BillingSummary,
|
||||
InvoiceQueryParams,
|
||||
InvoiceListQuery,
|
||||
InvoiceSsoQuery,
|
||||
InvoicePaymentLinkQuery,
|
||||
} from "./schema.js";
|
||||
|
||||
// Provider adapters
|
||||
|
||||
@ -119,6 +119,25 @@ export const invoiceListQuerySchema = z.object({
|
||||
|
||||
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)
|
||||
// ============================================================================
|
||||
|
||||
@ -18,9 +18,13 @@ export {
|
||||
notificationSchema,
|
||||
createNotificationRequestSchema,
|
||||
notificationListResponseSchema,
|
||||
notificationUnreadCountResponseSchema,
|
||||
notificationQuerySchema,
|
||||
// Types
|
||||
type Notification,
|
||||
type CreateNotificationRequest,
|
||||
type NotificationTemplate,
|
||||
type NotificationListResponse,
|
||||
type NotificationUnreadCountResponse,
|
||||
type NotificationQuery,
|
||||
} from "./schema.js";
|
||||
|
||||
@ -207,3 +207,20 @@ export const notificationListResponseSchema = z.object({
|
||||
});
|
||||
|
||||
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>;
|
||||
|
||||
@ -74,6 +74,7 @@ export type {
|
||||
CreateOrderRequest,
|
||||
OrderBusinessValidation,
|
||||
SfOrderIdParam,
|
||||
OrderListResponse,
|
||||
// Display types
|
||||
OrderDisplayItem,
|
||||
OrderDisplayItemCategory,
|
||||
|
||||
@ -318,6 +318,46 @@ export const checkoutBuildCartRequestSchema = z.object({
|
||||
|
||||
export const checkoutBuildCartResponseSchema = apiSuccessResponseSchema(checkoutCartSchema);
|
||||
|
||||
// ============================================================================
|
||||
// BFF endpoint request/param schemas (DTO inputs)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Body for POST /orders/from-checkout-session
|
||||
*/
|
||||
export const checkoutSessionCreateOrderRequestSchema = z.object({
|
||||
checkoutSessionId: z.string().uuid(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Params for GET /checkout/session/:sessionId
|
||||
*/
|
||||
export const checkoutSessionIdParamSchema = z.object({
|
||||
sessionId: z.string().uuid(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// BFF endpoint response schemas (shared contracts)
|
||||
// ============================================================================
|
||||
|
||||
export const checkoutCartSummarySchema = z.object({
|
||||
items: z.array(checkoutItemSchema),
|
||||
totals: checkoutTotalsSchema,
|
||||
});
|
||||
|
||||
export const checkoutSessionResponseSchema = apiSuccessResponseSchema(
|
||||
z.object({
|
||||
sessionId: z.string().uuid(),
|
||||
expiresAt: z.string(),
|
||||
orderType: z.enum(["Internet", "SIM", "VPN", "Other"]),
|
||||
cart: checkoutCartSummarySchema,
|
||||
})
|
||||
);
|
||||
|
||||
export const checkoutValidateCartResponseSchema = apiSuccessResponseSchema(
|
||||
z.object({ valid: z.boolean() })
|
||||
);
|
||||
|
||||
/**
|
||||
* Schema for order creation response
|
||||
*/
|
||||
@ -327,6 +367,10 @@ export const orderCreateResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const orderListResponseSchema = z.array(orderSummarySchema);
|
||||
|
||||
export type OrderListResponse = z.infer<typeof orderListResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// 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 CheckoutBuildCartRequest = z.infer<typeof checkoutBuildCartRequestSchema>;
|
||||
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)
|
||||
|
||||
@ -28,20 +28,6 @@ export const PAYMENT_GATEWAY_TYPE = {
|
||||
MANUAL: "manual",
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Business Types (Not validated at runtime)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Invoice payment link - not validated at runtime
|
||||
* This is a business domain type used internally
|
||||
*/
|
||||
export interface InvoicePaymentLink {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
gatewayName?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Re-export Types from Schema (Schema-First Approach)
|
||||
// ============================================================================
|
||||
@ -53,4 +39,5 @@ export type {
|
||||
PaymentGatewayType,
|
||||
PaymentGateway,
|
||||
PaymentGatewayList,
|
||||
InvoicePaymentLink,
|
||||
} from "./schema.js";
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
// Constants
|
||||
export { PAYMENT_METHOD_TYPE, PAYMENT_GATEWAY_TYPE, type InvoicePaymentLink } from "./contract.js";
|
||||
export { PAYMENT_METHOD_TYPE, PAYMENT_GATEWAY_TYPE } from "./contract.js";
|
||||
|
||||
// Schemas (includes derived types)
|
||||
export * from "./schema.js";
|
||||
|
||||
@ -55,6 +55,12 @@ export const paymentGatewayListSchema = z.object({
|
||||
totalCount: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export const invoicePaymentLinkSchema = z.object({
|
||||
url: z.string().url(),
|
||||
expiresAt: z.string(),
|
||||
gatewayName: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Inferred Types from Schemas (Schema-First Approach)
|
||||
// ============================================================================
|
||||
@ -65,3 +71,4 @@ export type PaymentMethodList = z.infer<typeof paymentMethodListSchema>;
|
||||
export type PaymentGatewayType = z.infer<typeof paymentGatewayTypeSchema>;
|
||||
export type PaymentGateway = z.infer<typeof paymentGatewaySchema>;
|
||||
export type PaymentGatewayList = z.infer<typeof paymentGatewayListSchema>;
|
||||
export type InvoicePaymentLink = z.infer<typeof invoicePaymentLinkSchema>;
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { addressSchema } from "../customer/index.js";
|
||||
|
||||
// ============================================================================
|
||||
// Base Catalog Product Schema
|
||||
@ -111,6 +112,15 @@ export const internetEligibilityDetailsSchema = z.object({
|
||||
notes: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Body for POST /services/internet/eligibility-request (BFF).
|
||||
* Lives in domain so Portal + BFF stay aligned.
|
||||
*/
|
||||
export const internetEligibilityRequestSchema = z.object({
|
||||
notes: z.string().trim().max(2000).optional(),
|
||||
address: addressSchema.partial().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// SIM Product Schemas
|
||||
// ============================================================================
|
||||
@ -184,6 +194,7 @@ export type InternetAddonCatalogItem = z.infer<typeof internetAddonCatalogItemSc
|
||||
export type InternetCatalogCollection = z.infer<typeof internetCatalogCollectionSchema>;
|
||||
export type InternetEligibilityStatus = z.infer<typeof internetEligibilityStatusSchema>;
|
||||
export type InternetEligibilityDetails = z.infer<typeof internetEligibilityDetailsSchema>;
|
||||
export type InternetEligibilityRequest = z.infer<typeof internetEligibilityRequestSchema>;
|
||||
|
||||
// SIM products
|
||||
export type SimCatalogProduct = z.infer<typeof simCatalogProductSchema>;
|
||||
|
||||
@ -48,6 +48,10 @@ export type {
|
||||
SimInternationalCallHistoryResponse,
|
||||
SimSmsRecord,
|
||||
SimSmsHistoryResponse,
|
||||
SimHistoryMonth,
|
||||
SimHistoryAvailableMonths,
|
||||
SimCallHistoryImportResult,
|
||||
SimSftpFiles,
|
||||
// Request types
|
||||
SimTopUpRequest,
|
||||
SimPlanChangeRequest,
|
||||
@ -63,6 +67,10 @@ export type {
|
||||
SimCancelFullRequest,
|
||||
SimTopUpFullRequest,
|
||||
SimChangePlanFullRequest,
|
||||
SimHistoryQuery,
|
||||
SimSftpListQuery,
|
||||
SimCallHistoryImportQuery,
|
||||
SimReissueEsimRequest,
|
||||
// Activation types
|
||||
SimOrderActivationRequest,
|
||||
SimOrderActivationMnp,
|
||||
|
||||
@ -234,7 +234,24 @@ export type SimReissueFullRequest = z.infer<typeof simReissueFullRequestSchema>;
|
||||
// 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 timeHmsSchema = z.string().regex(/^\d{2}:\d{2}:\d{2}$/, "Time must be in HH:MM:SS format");
|
||||
|
||||
@ -288,6 +305,47 @@ export type SimInternationalCallHistoryResponse = z.infer<
|
||||
typeof simInternationalCallHistoryResponseSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Schema for SIM history query parameters (pagination + month)
|
||||
*/
|
||||
export const simHistoryQuerySchema = z.object({
|
||||
month: simHistoryMonthSchema.optional(),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
|
||||
});
|
||||
|
||||
export type SimHistoryQuery = z.infer<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({
|
||||
id: z.string(),
|
||||
date: isoDateSchema,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user