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