diff --git a/src/common/filters/validation-exception.filter.ts b/src/common/filters/validation-exception.filter.ts new file mode 100644 index 00000000..5924f165 --- /dev/null +++ b/src/common/filters/validation-exception.filter.ts @@ -0,0 +1,45 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + BadRequestException, + HttpStatus, +} from '@nestjs/common'; +import { Response, Request } from 'express'; +import { ErrorResponseDto } from '../errors/error.dto'; + +@Catch(BadRequestException) +export class ValidationExceptionFilter implements ExceptionFilter { + catch(exception: BadRequestException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + let message = 'Validation failed'; + let details: string[] = []; + + if (typeof exceptionResponse === 'object' && (exceptionResponse as any).message) { + const msg = (exceptionResponse as any).message; + if (Array.isArray(msg)) { + details = msg; + message = 'One or more validation errors occurred'; + } else { + message = msg; + } + } + + const errorResponse = new ErrorResponseDto({ + statusCode: status, + errorCode: 'VALIDATION_ERROR', + message: message, + details: details, + path: request.url, + timestamp: new Date().toISOString(), + requestId: request.headers['x-correlation-id'] as string || (request as any).id, + }); + + response.status(status).json(errorResponse); + } +} diff --git a/src/common/validators/year-not-future.validator.ts b/src/common/validators/year-not-future.validator.ts new file mode 100644 index 00000000..54018958 --- /dev/null +++ b/src/common/validators/year-not-future.validator.ts @@ -0,0 +1,32 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, + registerDecorator, + ValidationOptions, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isYearNotFuture', async: false }) +export class IsYearNotFutureConstraint implements ValidatorConstraintInterface { + validate(year: any) { + if (typeof year !== 'number') return false; + const currentYear = new Date().getFullYear(); + return year <= currentYear + 1; // Allow up to next year for planned developments + } + + defaultMessage(args: ValidationArguments) { + return `Year ${args.value} cannot be in the distant future`; + } +} + +export function IsYearNotFuture(validationOptions?: ValidationOptions) { + return function (object: any, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsYearNotFutureConstraint, + }); + }; +} diff --git a/src/feature-flags/middleware/feature-flag.middleware.ts b/src/feature-flags/middleware/feature-flag.middleware.ts index a62e4340..533f159f 100644 --- a/src/feature-flags/middleware/feature-flag.middleware.ts +++ b/src/feature-flags/middleware/feature-flag.middleware.ts @@ -97,29 +97,29 @@ export class FeatureFlagMiddleware implements NestMiddleware { // Add custom user attributes if (req.user.plan) { - context.customAttributes!.plan = req.user.plan; + context.customAttributes.plan = req.user.plan; } if (req.user.region) { - context.customAttributes!.region = req.user.region; + context.customAttributes.region = req.user.region; } if (req.user.createdAt) { - context.customAttributes!.userAge = Date.now() - new Date(req.user.createdAt).getTime(); + context.customAttributes.userAge = Date.now() - new Date(req.user.createdAt).getTime(); } } // Add request-specific attributes if (req.get('X-Country')) { - context.customAttributes!.country = req.get('X-Country'); + context.customAttributes.country = req.get('X-Country'); } if (req.get('X-Device-Type')) { - context.customAttributes!.deviceType = req.get('X-Device-Type'); + context.customAttributes.deviceType = req.get('X-Device-Type'); } if (req.get('X-App-Version')) { - context.customAttributes!.appVersion = req.get('X-App-Version'); + context.customAttributes.appVersion = req.get('X-App-Version'); } return context; diff --git a/src/main.ts b/src/main.ts index 5899e629..bf2b12d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import { ErrorResponseDto } from './common/errors/error.dto'; import { SecurityHeadersService } from './security/services/security-headers.service'; import { DEFAULT_API_VERSION } from './common/api-version'; import { CorsOriginValidator } from './config/utils/cors-origin.validator'; +import { ValidationExceptionFilter } from './common/filters/validation-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -93,7 +94,7 @@ async function bootstrap() { logger.log(`CORS configured with origins: ${corsOrigins.join(', ')}`); - // Global pipes + app.useGlobalFilters(new ValidationExceptionFilter()); app.useGlobalPipes( new ValidationPipe({ whitelist: true, diff --git a/src/transactions/dto/create-transaction.dto.ts b/src/transactions/dto/create-transaction.dto.ts index 404231b2..4246f8f5 100644 --- a/src/transactions/dto/create-transaction.dto.ts +++ b/src/transactions/dto/create-transaction.dto.ts @@ -1,21 +1,96 @@ +import { + IsString, + IsNumber, + IsNotEmpty, + IsOptional, + IsEnum, + IsPositive, + IsUUID, + Min, + Max, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { TransactionType } from '../enums/transaction-type.enum'; +import { IsEthereumAddress } from '../../common/validators/is-ethereum-address.validator'; +import { IsXssSafe } from '../../common/validators/xss.validator'; +import { IsNotSqlInjection } from '../../common/validators/sql-injection.validator'; + export class CreateTransactionDto { + @ApiProperty({ description: 'Sender wallet address', example: '0x123...' }) + @IsNotEmpty({ message: 'From address is required' }) + @IsEthereumAddress({ message: 'Invalid sender wallet address' }) fromAddress: string; + + @ApiProperty({ description: 'Receiver wallet address', example: '0x456...' }) + @IsNotEmpty({ message: 'To address is required' }) + @IsEthereumAddress({ message: 'Invalid receiver wallet address' }) toAddress: string; + + @ApiProperty({ description: 'Amount for the transaction', example: 100.5, minimum: 0 }) + @IsNumber({}, { message: 'Amount must be a number' }) + @IsPositive({ message: 'Amount must be positive' }) + @Min(0.000001, { message: 'Amount is too small' }) amount: number; - type: string; + + @ApiProperty({ description: 'Type of transaction', enum: TransactionType }) + @IsEnum(TransactionType, { message: 'Invalid transaction type' }) + type: TransactionType; + + @ApiProperty({ description: 'Buyer user ID', example: 'user-123' }) + @IsString({ message: 'Buyer ID must be a string' }) + @IsNotEmpty({ message: 'Buyer ID is required' }) buyerId: string; + + @ApiProperty({ description: 'Seller user ID', example: 'user-456' }) + @IsString({ message: 'Seller ID must be a string' }) + @IsNotEmpty({ message: 'Seller ID is required' }) sellerId: string; + + @ApiProperty({ description: 'Currency code', example: 'USD' }) + @IsString({ message: 'Currency must be a string' }) + @IsNotEmpty({ message: 'Currency is required' }) + @IsXssSafe({ message: 'Currency contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Currency contains potential SQL injection' }) currency: string; + + @ApiPropertyOptional({ description: 'Property ID related to this transaction', example: 'prop-123' }) + @IsOptional() + @IsString({ message: 'Property ID must be a string' }) propertyId?: string; + + @ApiPropertyOptional({ description: 'Transaction hash if already submitted', example: '0xabc...' }) + @IsOptional() + @IsString({ message: 'Transaction hash must be a string' }) txHash?: string; } -export class PaginationParams { +export class PaginationParamsDto { + @ApiPropertyOptional({ description: 'Page number', example: 1, minimum: 1 }) + @IsOptional() + @IsNumber({}, { message: 'Page must be a number' }) + @Min(1) page?: number; + + @ApiPropertyOptional({ description: 'Limit items per page', example: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @IsNumber({}, { message: 'Limit must be a number' }) + @Min(1) + @Max(100) limit?: number; } export class DisputeDto { + @ApiProperty({ description: 'Reason for dispute', example: 'Property not as described' }) + @IsString({ message: 'Reason must be a string' }) + @IsNotEmpty({ message: 'Reason is required' }) + @IsXssSafe({ message: 'Reason contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Reason contains potential SQL injection' }) reason: string; + + @ApiPropertyOptional({ description: 'Details about the dispute', example: 'The roof has a leak...' }) + @IsOptional() + @IsString({ message: 'Details must be a string' }) + @IsXssSafe({ message: 'Details contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Details contains potential SQL injection' }) details?: string; } diff --git a/src/transactions/dto/transaction-query.dto.ts b/src/transactions/dto/transaction-query.dto.ts index e69de29b..719322c4 100644 --- a/src/transactions/dto/transaction-query.dto.ts +++ b/src/transactions/dto/transaction-query.dto.ts @@ -0,0 +1,47 @@ +import { IsOptional, IsEnum, IsString, IsNumber, Min, Max } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { TransactionStatus } from '../enums/transaction-status.enum'; +import { TransactionType } from '../enums/transaction-type.enum'; +import { Type } from 'class-transformer'; + +export class TransactionQueryDto { + @ApiPropertyOptional({ description: 'Filter by transaction status', enum: TransactionStatus }) + @IsOptional() + @IsEnum(TransactionStatus) + status?: TransactionStatus; + + @ApiPropertyOptional({ description: 'Filter by transaction type', enum: TransactionType }) + @IsOptional() + @IsEnum(TransactionType) + type?: TransactionType; + + @ApiPropertyOptional({ description: 'Filter by buyer ID' }) + @IsOptional() + @IsString() + buyerId?: string; + + @ApiPropertyOptional({ description: 'Filter by seller ID' }) + @IsOptional() + @IsString() + sellerId?: string; + + @ApiPropertyOptional({ description: 'Filter by property ID' }) + @IsOptional() + @IsString() + propertyId?: string; + + @ApiPropertyOptional({ description: 'Page number', example: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page', example: 20 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/src/transactions/dto/update-transaction.dto.ts b/src/transactions/dto/update-transaction.dto.ts index e69de29b..0f73e6a0 100644 --- a/src/transactions/dto/update-transaction.dto.ts +++ b/src/transactions/dto/update-transaction.dto.ts @@ -0,0 +1,12 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateTransactionDto } from './create-transaction.dto'; +import { IsEnum, IsOptional } from 'class-validator'; +import { TransactionStatus } from '../enums/transaction-status.enum'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateTransactionDto extends PartialType(CreateTransactionDto) { + @ApiPropertyOptional({ description: 'Update transaction status', enum: TransactionStatus }) + @IsOptional() + @IsEnum(TransactionStatus, { message: 'Invalid transaction status' }) + status?: TransactionStatus; +} diff --git a/src/transactions/enums/transaction-status.enum.ts b/src/transactions/enums/transaction-status.enum.ts index e69de29b..f921333c 100644 --- a/src/transactions/enums/transaction-status.enum.ts +++ b/src/transactions/enums/transaction-status.enum.ts @@ -0,0 +1,13 @@ +export enum TransactionStatus { + PENDING = 'PENDING', + PROCESSING = 'PROCESSING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', + CANCELLED = 'CANCELLED', + ESCROW_FUNDED = 'ESCROW_FUNDED', + BLOCKCHAIN_SUBMITTED = 'BLOCKCHAIN_SUBMITTED', + CONFIRMING = 'CONFIRMING', + CONFIRMED = 'CONFIRMED', + DISPUTED = 'DISPUTED', + REFUNDED = 'REFUNDED', +} diff --git a/src/transactions/enums/transaction-type.enum.ts b/src/transactions/enums/transaction-type.enum.ts index e69de29b..81dbdb07 100644 --- a/src/transactions/enums/transaction-type.enum.ts +++ b/src/transactions/enums/transaction-type.enum.ts @@ -0,0 +1,6 @@ +export enum TransactionType { + PURCHASE = 'PURCHASE', + TRANSFER = 'TRANSFER', + ESCROW = 'ESCROW', + REFUND = 'REFUND', +} diff --git a/src/transactions/transactions.controller.ts b/src/transactions/transactions.controller.ts index afc02300..ff9b9a75 100644 --- a/src/transactions/transactions.controller.ts +++ b/src/transactions/transactions.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { TransactionsService } from './transactions.service'; -import { CreateTransactionDto, DisputeDto, PaginationParams } from './dto/create-transaction.dto'; +import { CreateTransactionDto, DisputeDto } from './dto/create-transaction.dto'; +import { TransactionQueryDto } from './dto/transaction-query.dto'; @Controller('transactions') export class TransactionsController { @@ -22,7 +23,7 @@ export class TransactionsController { } @Get() - findAll(@Query() query: PaginationParams) { + findAll(@Query() query: TransactionQueryDto) { return this.service.findAll(query); } diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index 29f61554..5aab6dda 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -3,7 +3,8 @@ import { PrismaService } from 'src/database'; import { BlockchainService } from '../blockchain/blockchain.service'; import { TransactionStatus, TransactionType } from 'src/models/transaction.entity'; -import { CreateTransactionDto, DisputeDto, PaginationParams } from './dto/create-transaction.dto'; +import { CreateTransactionDto, DisputeDto } from './dto/create-transaction.dto'; +import { TransactionQueryDto } from './dto/transaction-query.dto'; @Injectable() export class TransactionsService { @@ -89,10 +90,23 @@ export class TransactionsService { return this.prisma.transaction.findUnique({ where: { id } }); } - async findAll(query: PaginationParams) { + async findAll(query: TransactionQueryDto) { const page = query.page || 1; const limit = query.limit || 20; - return this.prisma.transaction.findMany({ skip: (page - 1) * limit, take: limit }); + + const where: any = {}; + if (query.status) where.status = query.status; + if (query.type) where.type = query.type; + if (query.buyerId) where.buyerId = query.buyerId; + if (query.sellerId) where.sellerId = query.sellerId; + if (query.propertyId) where.propertyId = query.propertyId; + + return this.prisma.transaction.findMany({ + where, + skip: (page - 1) * limit, + take: limit, + orderBy: { createdAt: 'desc' } + }); } async raiseDispute(id: string, dto: DisputeDto) { diff --git a/src/valuation/dto/batch-valuation-request.dto.ts b/src/valuation/dto/batch-valuation-request.dto.ts new file mode 100644 index 00000000..4455b5e6 --- /dev/null +++ b/src/valuation/dto/batch-valuation-request.dto.ts @@ -0,0 +1,35 @@ +import { + IsString, + IsArray, + ValidateNested, + IsNotEmpty, + IsOptional, + ArrayNotEmpty, + ArrayMaxSize, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PropertyFeaturesDto } from './property-features.dto'; + +export class PropertyValuationItemDto { + @ApiProperty({ description: 'ID of the property to value', example: 'prop-123' }) + @IsString({ message: 'Property ID must be a string' }) + @IsNotEmpty({ message: 'Property ID is required' }) + propertyId: string; + + @ApiPropertyOptional({ description: 'Property features for valuation', type: PropertyFeaturesDto }) + @IsOptional() + @ValidateNested({ message: 'Features must be a valid features object' }) + @Type(() => PropertyFeaturesDto) + features?: PropertyFeaturesDto; +} + +export class BatchValuationRequestDto { + @ApiProperty({ description: 'Array of property valuation items', type: [PropertyValuationItemDto] }) + @IsArray({ message: 'Properties must be an array' }) + @ArrayNotEmpty({ message: 'Array of properties cannot be empty' }) + @ArrayMaxSize(50, { message: 'Maximum 50 properties can be valuated in a single batch' }) + @ValidateNested({ each: true, message: 'Each property item must be valid' }) + @Type(() => PropertyValuationItemDto) + properties: PropertyValuationItemDto[]; +} diff --git a/src/valuation/dto/property-features.dto.ts b/src/valuation/dto/property-features.dto.ts new file mode 100644 index 00000000..dbf129fb --- /dev/null +++ b/src/valuation/dto/property-features.dto.ts @@ -0,0 +1,73 @@ +import { + IsString, + IsNumber, + IsOptional, + Min, + Max, + IsNotEmpty, + MaxLength, + IsEnum, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsYearNotFuture } from '../../common/validators/year-not-future.validator'; +import { IsXssSafe } from '../../common/validators/xss.validator'; +import { IsNotSqlInjection } from '../../common/validators/sql-injection.validator'; +import { PropertyType } from '../../properties/dto/create-property.dto'; + +export class PropertyFeaturesDto { + @ApiPropertyOptional({ description: 'Internal property ID', example: 'prop-123' }) + @IsOptional() + @IsString({ message: 'ID must be a string' }) + id?: string; + + @ApiProperty({ description: 'Property location/address', example: '123 Main St, New York, NY' }) + @IsString({ message: 'Location must be a string' }) + @IsNotEmpty({ message: 'Location is required' }) + @MaxLength(500, { message: 'Location is too long' }) + @IsXssSafe({ message: 'Location contains potentially malicious content' }) + @IsNotSqlInjection({ message: 'Location contains potential SQL injection' }) + location: string; + + @ApiPropertyOptional({ description: 'Number of bedrooms', example: 3, minimum: 0, maximum: 50 }) + @IsOptional() + @IsNumber({}, { message: 'Bedrooms must be a number' }) + @Min(0, { message: 'Bedrooms cannot be negative' }) + @Max(50, { message: 'Bedrooms cannot exceed 50' }) + bedrooms?: number; + + @ApiPropertyOptional({ description: 'Number of bathrooms', example: 2.5, minimum: 0, maximum: 50 }) + @IsOptional() + @IsNumber({}, { message: 'Bathrooms must be a number' }) + @Min(0, { message: 'Bathrooms cannot be negative' }) + @Max(50, { message: 'Bathrooms cannot exceed 50' }) + bathrooms?: number; + + @ApiPropertyOptional({ description: 'Square footage', example: 1500, minimum: 10, maximum: 1000000 }) + @IsOptional() + @IsNumber({}, { message: 'Square footage must be a number' }) + @Min(10, { message: 'Square footage must be at least 10' }) + @Max(1000000, { message: 'Square footage cannot exceed 1,000,000' }) + squareFootage?: number; + + @ApiPropertyOptional({ description: 'Year built', example: 2010, minimum: 1600 }) + @IsOptional() + @IsNumber({}, { message: 'Year built must be a number' }) + @Min(1600, { message: 'Year built must be after 1600' }) + @IsYearNotFuture({ message: 'Year built cannot be in the distant future' }) + yearBuilt?: number; + + @ApiPropertyOptional({ description: 'Type of property', enum: PropertyType, example: PropertyType.RESIDENTIAL }) + @IsOptional() + @IsEnum(PropertyType, { message: 'Invalid property type' }) + propertyType?: PropertyType; + + @ApiPropertyOptional({ description: 'Lot size in acres', example: 0.25, minimum: 0, maximum: 1000 }) + @IsOptional() + @IsNumber({}, { message: 'Lot size must be a number' }) + @Min(0, { message: 'Lot size cannot be negative' }) + @Max(1000, { message: 'Lot size cannot exceed 1000 acres' }) + lotSize?: number; + + // Allow additional properties to maintain compatibility with PropertyFeatures interface + [key: string]: string | number | boolean | undefined | any; +} diff --git a/src/valuation/valuation.controller.ts b/src/valuation/valuation.controller.ts index 7065ac9c..2b26f14b 100644 --- a/src/valuation/valuation.controller.ts +++ b/src/valuation/valuation.controller.ts @@ -1,7 +1,9 @@ import { Controller, Get, Post, Param, Body, ValidationPipe, HttpCode, HttpStatus, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger'; import { ValuationService } from './valuation.service'; -import { PropertyFeatures, ValuationResult } from './valuation.types'; +import { ValuationResult } from './valuation.types'; +import { PropertyFeaturesDto } from './dto/property-features.dto'; +import { BatchValuationRequestDto } from './dto/batch-valuation-request.dto'; @ApiTags('valuation') @Controller('valuation') @@ -20,7 +22,7 @@ export class ValuationController { @HttpCode(HttpStatus.OK) async getValuation( @Param('propertyId') propertyId: string, - @Body(ValidationPipe) features?: PropertyFeatures, + @Body() features?: PropertyFeaturesDto, ): Promise { this.logger.log(`Requesting valuation for property ${propertyId}`); return this.valuationService.getValuation(propertyId, features); @@ -82,7 +84,7 @@ export class ValuationController { @ApiResponse({ status: 200, description: 'Batch valuations retrieved' }) @HttpCode(HttpStatus.OK) async getBatchValuations( - @Body() requestBody: { properties: Array<{ propertyId: string; features?: PropertyFeatures }> }, + @Body() requestBody: BatchValuationRequestDto, ) { const results = []; for (const item of requestBody.properties) {