diff --git a/.gitignore b/.gitignore index 9bfffd4..fc29fd3 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,7 @@ node_modules/ # Yarn Integrity file .yarn-integrity -.DS_Store \ No newline at end of file +.DS_Store + +# Claude +.claude/ \ No newline at end of file diff --git a/docs/dto-validation-guide.md b/docs/dto-validation-guide.md new file mode 100644 index 0000000..516bb77 --- /dev/null +++ b/docs/dto-validation-guide.md @@ -0,0 +1,256 @@ +# DTO-Based Validation Implementation Guide + +## Overview + +This guide documents the comprehensive DTO-based validation system implemented using `class-validator` and `class-transformer`. This system replaces the previous express-validator approach with a more robust, type-safe validation mechanism that aligns with our Domain-Driven Design (DDD) architecture. + +## Architecture + +### Core Components + +1. **Validation Middleware** (`src/shared/middleware/validation.middleware.ts`) + + - `validateDto()` - Validates request body + - `validateQueryDto()` - Validates query parameters + - `validateParamsDto()` - Validates route parameters + +2. **Base DTOs** (`src/shared/dto/base.dto.ts`) + + - `UuidParamsDto` - For UUID route parameters + - `PaginationQueryDto` - For pagination query parameters + - `BaseResponseDto` - Base response structure + - `ErrorResponseDto` - Error response structure + +3. **Module-Specific DTOs** + - Auth: `RegisterDto`, `LoginDto`, `VerifyEmailDTO`, `ResendVerificationDTO` + - Organization: `CreateOrganizationDto`, `UpdateOrganizationDto` + - User: `CreateUserDto`, `UpdateUserDto` + - Project: `CreateProjectDto`, `UpdateProjectDto` + - NFT: `CreateNFTDto` + - Messaging: `SendMessageDto`, `MarkAsReadDto` + - Volunteer: `CreateVolunteerDTO`, `UpdateVolunteerDTO` + +## Usage Examples + +### 1. Route-Level Validation + +```typescript +import { Router } from "express"; +import { + validateDto, + validateParamsDto, + validateQueryDto, +} from "../shared/middleware/validation.middleware"; +import { CreateOrganizationDto } from "../modules/organization/presentation/dto/create-organization.dto"; +import { UuidParamsDto, PaginationQueryDto } from "../shared/dto/base.dto"; + +const router = Router(); + +// POST with body validation +router.post( + "/organizations", + validateDto(CreateOrganizationDto), + organizationController.create +); + +// GET with parameter validation +router.get( + "/organizations/:id", + validateParamsDto(UuidParamsDto), + organizationController.getById +); + +// GET with query validation +router.get( + "/organizations", + validateQueryDto(PaginationQueryDto), + organizationController.getAll +); +``` + +### 2. Controller Type Safety + +```typescript +import { Request, Response } from "express"; +import { CreateOrganizationDto } from "../dto/create-organization.dto"; +import { UuidParamsDto, PaginationQueryDto } from "../../shared/dto/base.dto"; + +export class OrganizationController { + createOrganization = async ( + req: Request<{}, {}, CreateOrganizationDto>, + res: Response + ): Promise => { + // req.body is now typed as CreateOrganizationDto + const organization = await this.createUseCase.execute(req.body); + res.status(201).json({ success: true, data: organization }); + }; + + getById = async ( + req: Request, + res: Response + ): Promise => { + // req.params.id is validated as UUID + const organization = await this.getUseCase.execute(req.params.id); + res.json({ success: true, data: organization }); + }; + + getAll = async ( + req: Request<{}, {}, {}, PaginationQueryDto>, + res: Response + ): Promise => { + // req.query is typed and validated + const { page, limit, search } = req.query; + const organizations = await this.getAllUseCase.execute({ + page, + limit, + search, + }); + res.json({ success: true, data: organizations }); + }; +} +``` + +### 3. Creating Custom DTOs + +```typescript +import { + IsString, + IsEmail, + MinLength, + MaxLength, + IsOptional, + IsUUID, +} from "class-validator"; + +export class CreateOrganizationDto { + @IsString({ message: "Name must be a string" }) + @MinLength(2, { message: "Name must be at least 2 characters long" }) + @MaxLength(100, { message: "Name cannot exceed 100 characters" }) + name: string; + + @IsEmail({}, { message: "Please provide a valid email address" }) + email: string; + + @IsString({ message: "Password must be a string" }) + @MinLength(8, { message: "Password must be at least 8 characters long" }) + password: string; + + @IsOptional() + @IsString({ message: "Description must be a string" }) + @MinLength(10, { message: "Description must be at least 10 characters long" }) + @MaxLength(500, { message: "Description cannot exceed 500 characters" }) + description?: string; +} +``` + +## Validation Rules + +### Common Validation Decorators + +- `@IsString()` - Validates string type +- `@IsEmail()` - Validates email format +- `@IsUUID(4)` - Validates UUID v4 format +- `@IsInt()`, `@IsNumber()` - Validates numeric types +- `@IsBoolean()` - Validates boolean type +- `@IsOptional()` - Makes field optional +- `@MinLength(n)`, `@MaxLength(n)` - String length validation +- `@Min(n)`, `@Max(n)` - Numeric range validation +- `@Matches(regex)` - Regular expression validation +- `@IsEnum(enum)` - Enum validation +- `@IsUrl()` - URL validation + +### Transform Decorators + +```typescript +import { Transform } from "class-transformer"; + +export class PaginationQueryDto { + @Transform(({ value }) => parseInt(value, 10)) + @IsInt({ message: "Page must be an integer" }) + @Min(1, { message: "Page must be at least 1" }) + page: number; +} +``` + +## Error Response Format + +When validation fails, the middleware returns a standardized error response: + +```json +{ + "success": false, + "error": "Validation failed", + "details": [ + { + "property": "email", + "value": "invalid-email", + "constraints": ["Please provide a valid email address"] + }, + { + "property": "name", + "value": "A", + "constraints": ["Name must be at least 2 characters long"] + } + ] +} +``` + +## Migration from express-validator + +### Before (express-validator) + +```typescript +import { body, param, validationResult } from "express-validator"; + +router.post( + "/nfts", + [ + body("userId").isUUID(), + body("organizationId").isUUID(), + body("description").isString().notEmpty(), + ], + NFTController.createNFT +); +``` + +### After (class-validator) + +```typescript +import { validateDto } from "../shared/middleware/validation.middleware"; +import { CreateNFTDto } from "../modules/nft/dto/create-nft.dto"; + +router.post("/nfts", validateDto(CreateNFTDto), NFTController.createNFT); +``` + +## Benefits + +1. **Type Safety** - Full TypeScript support with typed request objects +2. **Centralized Validation** - All validation rules defined in DTO classes +3. **Reusability** - DTOs can be reused across different endpoints +4. **Consistency** - Standardized error responses +5. **Maintainability** - Easy to update validation rules in one place +6. **DDD Alignment** - Fits well with Domain-Driven Design principles +7. **Auto-transformation** - Automatic type conversion with class-transformer + +## Testing + +Test files are available in `src/shared/middleware/__tests__/validation.middleware.test.ts` demonstrating proper testing of validation middleware. + +## Migration Checklist + +- [ ] Replace express-validator imports with class-validator DTOs +- [ ] Update route handlers to use validation middleware +- [ ] Update controller method signatures with proper typing +- [ ] Test all endpoints with both valid and invalid data +- [ ] Update API documentation with new validation rules +- [ ] Remove unused express-validator dependencies (optional) + +## Best Practices + +1. Always provide descriptive error messages in validation decorators +2. Use appropriate validation decorators for each field type +3. Group related validations in the same DTO class +4. Use base DTOs for common patterns (UUID params, pagination) +5. Keep DTOs focused and specific to their use case +6. Test validation logic thoroughly +7. Document custom validation rules diff --git a/src/modules/auth/dto/login.dto.ts b/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..ec4080c --- /dev/null +++ b/src/modules/auth/dto/login.dto.ts @@ -0,0 +1,10 @@ +import { IsString, IsEmail, IsNotEmpty } from "class-validator"; + +export class LoginDto { + @IsEmail({}, { message: "Please provide a valid email address" }) + email: string; + + @IsString({ message: "Password must be a string" }) + @IsNotEmpty({ message: "Password is required" }) + password: string; +} diff --git a/src/modules/auth/dto/register.dto.ts b/src/modules/auth/dto/register.dto.ts new file mode 100644 index 0000000..0e7dbf2 --- /dev/null +++ b/src/modules/auth/dto/register.dto.ts @@ -0,0 +1,32 @@ +import { + IsString, + IsEmail, + MinLength, + MaxLength, + IsOptional, +} from "class-validator"; + +export class RegisterDto { + @IsString({ message: "Name must be a string" }) + @MinLength(2, { message: "Name must be at least 2 characters long" }) + @MaxLength(100, { message: "Name cannot exceed 100 characters" }) + name: string; + + @IsEmail({}, { message: "Please provide a valid email address" }) + email: string; + + @IsString({ message: "Password must be a string" }) + @MinLength(8, { message: "Password must be at least 8 characters long" }) + @MaxLength(128, { message: "Password cannot exceed 128 characters" }) + password: string; + + @IsOptional() + @IsString({ message: "Wallet address must be a string" }) + @MinLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @MaxLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + walletAddress?: string; +} diff --git a/src/modules/auth/dto/resendVerificationDTO.ts b/src/modules/auth/dto/resendVerificationDTO.ts index cbe5b4a..27b7d98 100644 --- a/src/modules/auth/dto/resendVerificationDTO.ts +++ b/src/modules/auth/dto/resendVerificationDTO.ts @@ -1,6 +1,6 @@ import { IsEmail } from "class-validator"; export class ResendVerificationDTO { - @IsEmail() + @IsEmail({}, { message: "Please provide a valid email address" }) email: string; } diff --git a/src/modules/auth/dto/verifyEmailDTO.ts b/src/modules/auth/dto/verifyEmailDTO.ts index 3d81e80..ff74d77 100644 --- a/src/modules/auth/dto/verifyEmailDTO.ts +++ b/src/modules/auth/dto/verifyEmailDTO.ts @@ -1,7 +1,7 @@ import { IsString, IsNotEmpty } from "class-validator"; export class VerifyEmailDTO { - @IsString() - @IsNotEmpty() + @IsString({ message: "Token must be a string" }) + @IsNotEmpty({ message: "Token is required" }) token: string; } diff --git a/src/modules/auth/dto/wallet-validation.dto.ts b/src/modules/auth/dto/wallet-validation.dto.ts new file mode 100644 index 0000000..3caf8cb --- /dev/null +++ b/src/modules/auth/dto/wallet-validation.dto.ts @@ -0,0 +1,35 @@ +import { IsString, MinLength, MaxLength, Matches } from "class-validator"; + +export class ValidateWalletFormatDto { + @IsString({ message: "Wallet address must be a string" }) + @MinLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @MaxLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @Matches(/^G[A-Z2-7]{55}$/, { + message: "Invalid Stellar wallet address format", + }) + walletAddress: string; +} + +export class VerifyWalletDto { + @IsString({ message: "Wallet address must be a string" }) + @MinLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @MaxLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @Matches(/^G[A-Z2-7]{55}$/, { + message: "Invalid Stellar wallet address format", + }) + walletAddress: string; + + @IsString({ message: "Signature must be a string" }) + signature: string; + + @IsString({ message: "Message must be a string" }) + message: string; +} diff --git a/src/modules/messaging/dto/message.dto.ts b/src/modules/messaging/dto/message.dto.ts index f8adec6..43df2e3 100644 --- a/src/modules/messaging/dto/message.dto.ts +++ b/src/modules/messaging/dto/message.dto.ts @@ -1,22 +1,22 @@ import { IsString, IsNotEmpty, IsUUID } from "class-validator"; export class SendMessageDto { - @IsString() - @IsNotEmpty() + @IsString({ message: "Content must be a string" }) + @IsNotEmpty({ message: "Content is required" }) content: string; - @IsUUID() - @IsNotEmpty() + @IsUUID(4, { message: "Receiver ID must be a valid UUID" }) + @IsNotEmpty({ message: "Receiver ID is required" }) receiverId: string; - @IsUUID() - @IsNotEmpty() + @IsUUID(4, { message: "Volunteer ID must be a valid UUID" }) + @IsNotEmpty({ message: "Volunteer ID is required" }) volunteerId: string; } export class MarkAsReadDto { - @IsUUID() - @IsNotEmpty() + @IsUUID(4, { message: "Message ID must be a valid UUID" }) + @IsNotEmpty({ message: "Message ID is required" }) messageId: string; } diff --git a/src/modules/nft/dto/create-nft.dto.ts b/src/modules/nft/dto/create-nft.dto.ts index dd7cad3..57c235b 100644 --- a/src/modules/nft/dto/create-nft.dto.ts +++ b/src/modules/nft/dto/create-nft.dto.ts @@ -1,5 +1,14 @@ -export interface CreateNFTDto { +import { IsString, IsUUID, MinLength, MaxLength } from "class-validator"; + +export class CreateNFTDto { + @IsUUID(4, { message: "User ID must be a valid UUID" }) userId: string; + + @IsUUID(4, { message: "Organization ID must be a valid UUID" }) organizationId: string; + + @IsString({ message: "Description must be a string" }) + @MinLength(10, { message: "Description must be at least 10 characters long" }) + @MaxLength(1000, { message: "Description cannot exceed 1000 characters" }) description: string; } diff --git a/src/modules/organization/presentation/controllers/organization.controller.ts b/src/modules/organization/presentation/controllers/organization.controller.ts index 70bee98..e306815 100644 --- a/src/modules/organization/presentation/controllers/organization.controller.ts +++ b/src/modules/organization/presentation/controllers/organization.controller.ts @@ -8,6 +8,10 @@ import { GetAllOrganizationsUseCase } from "../../application/use-cases/get-all- import { CreateOrganizationDto } from "../dto/create-organization.dto"; import { UpdateOrganizationDto } from "../dto/update-organization.dto"; import { OrganizationNotFoundException } from "../../domain/exceptions/organization-not-found.exception"; +import { + UuidParamsDto, + PaginationQueryDto, +} from "../../../shared/dto/base.dto"; export class OrganizationController { constructor( @@ -19,11 +23,12 @@ export class OrganizationController { ) {} createOrganization = asyncHandler( - async (req: Request, res: Response): Promise => { - const createOrganizationDto = req.body as CreateOrganizationDto; - + async ( + req: Request, + res: Response + ): Promise => { const organization = await this.createOrganizationUseCase.execute( - createOrganizationDto + req.body ); res.status(201).json({ @@ -35,7 +40,7 @@ export class OrganizationController { ); getOrganizationById = asyncHandler( - async (req: Request, res: Response): Promise => { + async (req: Request, res: Response): Promise => { const { id } = req.params; try { @@ -59,14 +64,16 @@ export class OrganizationController { ); updateOrganization = asyncHandler( - async (req: Request, res: Response): Promise => { + async ( + req: Request, + res: Response + ): Promise => { const { id } = req.params; - const updateOrganizationDto = req.body as UpdateOrganizationDto; try { const organization = await this.updateOrganizationUseCase.execute( id, - updateOrganizationDto + req.body ); res.status(200).json({ @@ -88,7 +95,7 @@ export class OrganizationController { ); deleteOrganization = asyncHandler( - async (req: Request, res: Response): Promise => { + async (req: Request, res: Response): Promise => { const { id } = req.params; try { @@ -109,21 +116,24 @@ export class OrganizationController { ); getAllOrganizations = asyncHandler( - async (req: Request, res: Response): Promise => { - const { page = 1, limit = 10, search } = req.query; + async ( + req: Request, + res: Response + ): Promise => { + const { page, limit, search } = req.query; const organizations = await this.getAllOrganizationsUseCase.execute({ - page: Number(page), - limit: Number(limit), - search: search as string, + page: page || 1, + limit: limit || 10, + search, }); res.status(200).json({ success: true, data: organizations, pagination: { - page: Number(page), - limit: Number(limit), + page: page || 1, + limit: limit || 10, total: organizations.length, }, }); diff --git a/src/modules/organization/presentation/dto/create-organization.dto.ts b/src/modules/organization/presentation/dto/create-organization.dto.ts index 1515d69..904b7ea 100644 --- a/src/modules/organization/presentation/dto/create-organization.dto.ts +++ b/src/modules/organization/presentation/dto/create-organization.dto.ts @@ -8,39 +8,39 @@ import { } from "class-validator"; export class CreateOrganizationDto { - @IsString() - @MinLength(2) - @MaxLength(100) + @IsString({ message: "Name must be a string" }) + @MinLength(2, { message: "Name must be at least 2 characters long" }) + @MaxLength(100, { message: "Name cannot exceed 100 characters" }) name: string; - @IsEmail() + @IsEmail({}, { message: "Please provide a valid email address" }) email: string; - @IsString() - @MinLength(8) + @IsString({ message: "Password must be a string" }) + @MinLength(8, { message: "Password must be at least 8 characters long" }) password: string; - @IsString() - @MinLength(10) - @MaxLength(500) + @IsString({ message: "Description must be a string" }) + @MinLength(10, { message: "Description must be at least 10 characters long" }) + @MaxLength(500, { message: "Description cannot exceed 500 characters" }) description: string; @IsOptional() - @IsString() - @MaxLength(50) + @IsString({ message: "Category must be a string" }) + @MaxLength(50, { message: "Category cannot exceed 50 characters" }) category?: string; @IsOptional() - @IsUrl() + @IsUrl({}, { message: "Please provide a valid website URL" }) website?: string; @IsOptional() - @IsString() - @MaxLength(200) + @IsString({ message: "Address must be a string" }) + @MaxLength(200, { message: "Address cannot exceed 200 characters" }) address?: string; @IsOptional() - @IsString() - @MaxLength(20) + @IsString({ message: "Phone must be a string" }) + @MaxLength(20, { message: "Phone cannot exceed 20 characters" }) phone?: string; } diff --git a/src/modules/organization/presentation/dto/update-organization.dto.ts b/src/modules/organization/presentation/dto/update-organization.dto.ts index cd12f3d..6dd037a 100644 --- a/src/modules/organization/presentation/dto/update-organization.dto.ts +++ b/src/modules/organization/presentation/dto/update-organization.dto.ts @@ -10,46 +10,46 @@ import { export class UpdateOrganizationDto { @IsOptional() - @IsString() - @MinLength(2) - @MaxLength(100) + @IsString({ message: "Name must be a string" }) + @MinLength(2, { message: "Name must be at least 2 characters long" }) + @MaxLength(100, { message: "Name cannot exceed 100 characters" }) name?: string; @IsOptional() - @IsEmail() + @IsEmail({}, { message: "Please provide a valid email address" }) email?: string; @IsOptional() - @IsString() - @MinLength(10) - @MaxLength(500) + @IsString({ message: "Description must be a string" }) + @MinLength(10, { message: "Description must be at least 10 characters long" }) + @MaxLength(500, { message: "Description cannot exceed 500 characters" }) description?: string; @IsOptional() - @IsString() - @MaxLength(50) + @IsString({ message: "Category must be a string" }) + @MaxLength(50, { message: "Category cannot exceed 50 characters" }) category?: string; @IsOptional() - @IsUrl() + @IsUrl({}, { message: "Please provide a valid website URL" }) website?: string; @IsOptional() - @IsString() - @MaxLength(200) + @IsString({ message: "Address must be a string" }) + @MaxLength(200, { message: "Address cannot exceed 200 characters" }) address?: string; @IsOptional() - @IsString() - @MaxLength(20) + @IsString({ message: "Phone must be a string" }) + @MaxLength(20, { message: "Phone cannot exceed 20 characters" }) phone?: string; @IsOptional() - @IsBoolean() + @IsBoolean({ message: "isVerified must be a boolean" }) isVerified?: boolean; @IsOptional() - @IsString() - @MaxLength(1000) + @IsString({ message: "Logo URL must be a string" }) + @MaxLength(1000, { message: "Logo URL cannot exceed 1000 characters" }) logoUrl?: string; } diff --git a/src/modules/project/dto/CreateProjectDto.ts b/src/modules/project/dto/CreateProjectDto.ts index bb21b59..b371123 100644 --- a/src/modules/project/dto/CreateProjectDto.ts +++ b/src/modules/project/dto/CreateProjectDto.ts @@ -1,8 +1,28 @@ +import { + IsString, + IsUUID, + IsOptional, + MinLength, + MaxLength, + IsEnum, +} from "class-validator"; import { ProjectStatus } from "../domain/Project"; -export interface CreateProjectDto { +export class CreateProjectDto { + @IsString({ message: "Title must be a string" }) + @MinLength(3, { message: "Title must be at least 3 characters long" }) + @MaxLength(200, { message: "Title cannot exceed 200 characters" }) title: string; + + @IsString({ message: "Description must be a string" }) + @MinLength(10, { message: "Description must be at least 10 characters long" }) + @MaxLength(2000, { message: "Description cannot exceed 2000 characters" }) description: string; + + @IsUUID(4, { message: "Organization ID must be a valid UUID" }) organizationId: string; + + @IsOptional() + @IsEnum(ProjectStatus, { message: "Status must be a valid project status" }) status?: ProjectStatus; } diff --git a/src/modules/project/dto/UpdateProjectDto.ts b/src/modules/project/dto/UpdateProjectDto.ts index 6d1a5bf..27c225a 100644 --- a/src/modules/project/dto/UpdateProjectDto.ts +++ b/src/modules/project/dto/UpdateProjectDto.ts @@ -1,8 +1,31 @@ +import { + IsString, + IsUUID, + IsOptional, + MinLength, + MaxLength, + IsEnum, +} from "class-validator"; import { ProjectStatus } from "../domain/Project"; -export interface UpdateProjectDto { +export class UpdateProjectDto { + @IsOptional() + @IsString({ message: "Title must be a string" }) + @MinLength(3, { message: "Title must be at least 3 characters long" }) + @MaxLength(200, { message: "Title cannot exceed 200 characters" }) title?: string; + + @IsOptional() + @IsString({ message: "Description must be a string" }) + @MinLength(10, { message: "Description must be at least 10 characters long" }) + @MaxLength(2000, { message: "Description cannot exceed 2000 characters" }) description?: string; + + @IsOptional() + @IsUUID(4, { message: "Organization ID must be a valid UUID" }) organizationId?: string; + + @IsOptional() + @IsEnum(ProjectStatus, { message: "Status must be a valid project status" }) status?: ProjectStatus; } diff --git a/src/modules/user/dto/CreateUserDto.ts b/src/modules/user/dto/CreateUserDto.ts index 8304423..6706c42 100644 --- a/src/modules/user/dto/CreateUserDto.ts +++ b/src/modules/user/dto/CreateUserDto.ts @@ -1,7 +1,41 @@ +import { + IsString, + IsEmail, + MinLength, + MaxLength, + IsOptional, + Matches, +} from "class-validator"; + export class CreateUserDto { + @IsString({ message: "Name must be a string" }) + @MinLength(2, { message: "Name must be at least 2 characters long" }) + @MaxLength(50, { message: "Name cannot exceed 50 characters" }) name: string; + + @IsString({ message: "Last name must be a string" }) + @MinLength(2, { message: "Last name must be at least 2 characters long" }) + @MaxLength(50, { message: "Last name cannot exceed 50 characters" }) lastName: string; + + @IsEmail({}, { message: "Please provide a valid email address" }) email: string; + + @IsString({ message: "Password must be a string" }) + @MinLength(8, { message: "Password must be at least 8 characters long" }) + @MaxLength(128, { message: "Password cannot exceed 128 characters" }) password: string; - wallet: string; + + @IsOptional() + @IsString({ message: "Wallet address must be a string" }) + @MinLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @MaxLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @Matches(/^G[A-Z2-7]{55}$/, { + message: "Invalid Stellar wallet address format", + }) + wallet?: string; } diff --git a/src/modules/user/dto/UpdateUserDto.ts b/src/modules/user/dto/UpdateUserDto.ts index 69f338e..8687e70 100644 --- a/src/modules/user/dto/UpdateUserDto.ts +++ b/src/modules/user/dto/UpdateUserDto.ts @@ -1,8 +1,45 @@ +import { + IsString, + IsEmail, + MinLength, + MaxLength, + IsOptional, + Matches, +} from "class-validator"; + export class UpdateUserDto { - id: string; - name: string; - lastName: string; - email: string; - password: string; - wallet: string; + @IsOptional() + @IsString({ message: "Name must be a string" }) + @MinLength(2, { message: "Name must be at least 2 characters long" }) + @MaxLength(50, { message: "Name cannot exceed 50 characters" }) + name?: string; + + @IsOptional() + @IsString({ message: "Last name must be a string" }) + @MinLength(2, { message: "Last name must be at least 2 characters long" }) + @MaxLength(50, { message: "Last name cannot exceed 50 characters" }) + lastName?: string; + + @IsOptional() + @IsEmail({}, { message: "Please provide a valid email address" }) + email?: string; + + @IsOptional() + @IsString({ message: "Password must be a string" }) + @MinLength(8, { message: "Password must be at least 8 characters long" }) + @MaxLength(128, { message: "Password cannot exceed 128 characters" }) + password?: string; + + @IsOptional() + @IsString({ message: "Wallet address must be a string" }) + @MinLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @MaxLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @Matches(/^G[A-Z2-7]{55}$/, { + message: "Invalid Stellar wallet address format", + }) + wallet?: string; } diff --git a/src/modules/volunteer/dto/volunteer.dto.ts b/src/modules/volunteer/dto/volunteer.dto.ts index 3a23f18..59bed04 100644 --- a/src/modules/volunteer/dto/volunteer.dto.ts +++ b/src/modules/volunteer/dto/volunteer.dto.ts @@ -1,15 +1,62 @@ -export interface CreateVolunteerDTO { +import { + IsString, + IsUUID, + IsOptional, + MinLength, + MaxLength, +} from "class-validator"; + +export class CreateVolunteerDTO { + @IsString({ message: "Name must be a string" }) + @MinLength(3, { message: "Name must be at least 3 characters long" }) + @MaxLength(200, { message: "Name cannot exceed 200 characters" }) name: string; + + @IsString({ message: "Description must be a string" }) + @MinLength(10, { message: "Description must be at least 10 characters long" }) + @MaxLength(2000, { message: "Description cannot exceed 2000 characters" }) description: string; + + @IsString({ message: "Requirements must be a string" }) + @MinLength(10, { + message: "Requirements must be at least 10 characters long", + }) + @MaxLength(1000, { message: "Requirements cannot exceed 1000 characters" }) requirements: string; + + @IsOptional() + @IsString({ message: "Incentive must be a string" }) + @MaxLength(500, { message: "Incentive cannot exceed 500 characters" }) incentive?: string; + + @IsUUID(4, { message: "Project ID must be a valid UUID" }) projectId: string; } -export interface UpdateVolunteerDTO { +export class UpdateVolunteerDTO { + @IsOptional() + @IsString({ message: "Name must be a string" }) + @MinLength(3, { message: "Name must be at least 3 characters long" }) + @MaxLength(200, { message: "Name cannot exceed 200 characters" }) name?: string; + + @IsOptional() + @IsString({ message: "Description must be a string" }) + @MinLength(10, { message: "Description must be at least 10 characters long" }) + @MaxLength(2000, { message: "Description cannot exceed 2000 characters" }) description?: string; + + @IsOptional() + @IsString({ message: "Requirements must be a string" }) + @MinLength(10, { + message: "Requirements must be at least 10 characters long", + }) + @MaxLength(1000, { message: "Requirements cannot exceed 1000 characters" }) requirements?: string; + + @IsOptional() + @IsString({ message: "Incentive must be a string" }) + @MaxLength(500, { message: "Incentive cannot exceed 500 characters" }) incentive?: string; } diff --git a/src/routes/nftRoutes.ts b/src/routes/nftRoutes.ts index 469ae2a..279cf05 100644 --- a/src/routes/nftRoutes.ts +++ b/src/routes/nftRoutes.ts @@ -1,21 +1,32 @@ import { Router } from "express"; import NFTController from "../modules/nft/presentation/controllers/NFTController.stub"; -import { body } from "express-validator"; +import { + validateDto, + validateParamsDto, +} from "../shared/middleware/validation.middleware"; +import { CreateNFTDto } from "../modules/nft/dto/create-nft.dto"; +import { UuidParamsDto } from "../shared/dto/base.dto"; const router = Router(); -router.post( - "/nfts", - [ - body("userId").isUUID(), - body("organizationId").isUUID(), - body("description").isString().notEmpty(), - ], - NFTController.createNFT +router.post("/nfts", validateDto(CreateNFTDto), NFTController.createNFT); + +router.get( + "/nfts/:id", + validateParamsDto(UuidParamsDto), + NFTController.getNFTById +); + +router.get( + "/users/:userId/nfts", + validateParamsDto(UuidParamsDto), + NFTController.getNFTsByUserId ); -router.get("/nfts/:id", NFTController.getNFTById); -router.get("/users/:userId/nfts", NFTController.getNFTsByUserId); -router.delete("/nfts/:id", NFTController.deleteNFT); +router.delete( + "/nfts/:id", + validateParamsDto(UuidParamsDto), + NFTController.deleteNFT +); export default router; diff --git a/src/routes/v2/auth.routes.ts b/src/routes/v2/auth.routes.ts new file mode 100644 index 0000000..61ffa43 --- /dev/null +++ b/src/routes/v2/auth.routes.ts @@ -0,0 +1,59 @@ +import { Router } from "express"; +import { validateDto } from "../../shared/middleware/validation.middleware"; +import { RegisterDto } from "../../modules/auth/dto/register.dto"; +import { LoginDto } from "../../modules/auth/dto/login.dto"; +import { ResendVerificationDTO } from "../../modules/auth/dto/resendVerificationDTO"; +import { VerifyEmailDTO } from "../../modules/auth/dto/verifyEmailDTO"; +import { + ValidateWalletFormatDto, + VerifyWalletDto, +} from "../../modules/auth/dto/wallet-validation.dto"; + +const router = Router(); + +// Note: This is an example of how to properly integrate validation middleware +// The controller would need to be properly instantiated with dependencies + +// POST /auth/register - User registration +router.post( + "/register", + validateDto(RegisterDto) + // authController.register +); + +// POST /auth/login - User login +router.post( + "/login", + validateDto(LoginDto) + // authController.login +); + +// POST /auth/resend-verification - Resend email verification +router.post( + "/resend-verification", + validateDto(ResendVerificationDTO) + // authController.resendVerificationEmail +); + +// POST /auth/verify-email - Verify email with token +router.post( + "/verify-email", + validateDto(VerifyEmailDTO) + // authController.verifyEmail +); + +// POST /auth/validate-wallet-format - Validate wallet address format +router.post( + "/validate-wallet-format", + validateDto(ValidateWalletFormatDto) + // authController.validateWalletFormat +); + +// POST /auth/verify-wallet - Verify wallet ownership +router.post( + "/verify-wallet", + validateDto(VerifyWalletDto) + // authController.verifyWallet +); + +export default router; diff --git a/src/routes/v2/organization.routes.ts b/src/routes/v2/organization.routes.ts new file mode 100644 index 0000000..b58e338 --- /dev/null +++ b/src/routes/v2/organization.routes.ts @@ -0,0 +1,55 @@ +import { Router } from "express"; +import { + validateDto, + validateParamsDto, + validateQueryDto, +} from "../../shared/middleware/validation.middleware"; +import { CreateOrganizationDto } from "../../modules/organization/presentation/dto/create-organization.dto"; +import { UpdateOrganizationDto } from "../../modules/organization/presentation/dto/update-organization.dto"; +import { UuidParamsDto, PaginationQueryDto } from "../../shared/dto/base.dto"; +import auth from "../../middleware/authMiddleware"; + +const router = Router(); + +// Note: This is an example of how to properly integrate validation middleware +// The controller would need to be properly instantiated with dependencies + +// POST /organizations - Create organization +router.post( + "/", + validateDto(CreateOrganizationDto) + // organizationController.createOrganization +); + +// GET /organizations - Get all organizations with pagination +router.get( + "/", + validateQueryDto(PaginationQueryDto) + // organizationController.getAllOrganizations +); + +// GET /organizations/:id - Get organization by ID +router.get( + "/:id", + validateParamsDto(UuidParamsDto) + // organizationController.getOrganizationById +); + +// PUT /organizations/:id - Update organization (protected) +router.put( + "/:id", + auth.authMiddleware, + validateParamsDto(UuidParamsDto), + validateDto(UpdateOrganizationDto) + // organizationController.updateOrganization +); + +// DELETE /organizations/:id - Delete organization (protected) +router.delete( + "/:id", + auth.authMiddleware, + validateParamsDto(UuidParamsDto) + // organizationController.deleteOrganization +); + +export default router; diff --git a/src/shared/dto/base.dto.ts b/src/shared/dto/base.dto.ts new file mode 100644 index 0000000..1f71a27 --- /dev/null +++ b/src/shared/dto/base.dto.ts @@ -0,0 +1,40 @@ +import { IsUUID, IsOptional, IsInt, Min, IsString } from "class-validator"; +import { Transform } from "class-transformer"; + +export class UuidParamsDto { + @IsUUID(4, { message: "ID must be a valid UUID" }) + id: string; +} + +export class PaginationQueryDto { + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt({ message: "Page must be an integer" }) + @Min(1, { message: "Page must be at least 1" }) + page?: number = 1; + + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt({ message: "Limit must be an integer" }) + @Min(1, { message: "Limit must be at least 1" }) + limit?: number = 10; + + @IsOptional() + @IsString({ message: "Search must be a string" }) + search?: string; +} + +export class BaseResponseDto { + success: boolean; + message?: string; +} + +export class ErrorResponseDto extends BaseResponseDto { + success: false; + error: string; + details?: Array<{ + property: string; + value: unknown; + constraints: string[]; + }>; +} diff --git a/src/shared/middleware/__tests__/validation.middleware.test.ts b/src/shared/middleware/__tests__/validation.middleware.test.ts new file mode 100644 index 0000000..99117c2 --- /dev/null +++ b/src/shared/middleware/__tests__/validation.middleware.test.ts @@ -0,0 +1,151 @@ +import { Request, Response } from "express"; +import { + validateDto, + validateQueryDto, + validateParamsDto, +} from "../validation.middleware"; +import { CreateOrganizationDto } from "../../../modules/organization/presentation/dto/create-organization.dto"; +import { UuidParamsDto, PaginationQueryDto } from "../../dto/base.dto"; +import "reflect-metadata"; + +describe("Validation Middleware", () => { + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: jest.Mock; + + beforeEach(() => { + mockRequest = {}; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + nextFunction = jest.fn(); + }); + + describe("validateDto", () => { + it("should pass validation with valid CreateOrganizationDto", async () => { + const validData = { + name: "Test Organization", + email: "test@example.com", + password: "password123", + description: "A test organization for unit testing purposes", + }; + + mockRequest.body = validData; + + const middleware = validateDto(CreateOrganizationDto); + await middleware( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it("should fail validation with invalid CreateOrganizationDto", async () => { + const invalidData = { + name: "A", // Too short + email: "invalid-email", // Invalid email + password: "123", // Too short + description: "Short", // Too short + }; + + mockRequest.body = invalidData; + + const middleware = validateDto(CreateOrganizationDto); + await middleware( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).not.toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: "Validation failed", + details: expect.arrayContaining([ + expect.objectContaining({ + property: expect.any(String), + constraints: expect.any(Array), + }), + ]), + }) + ); + }); + }); + + describe("validateParamsDto", () => { + it("should pass validation with valid UUID", async () => { + mockRequest.params = { + id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const middleware = validateParamsDto(UuidParamsDto); + await middleware( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it("should fail validation with invalid UUID", async () => { + mockRequest.params = { + id: "invalid-uuid", + }; + + const middleware = validateParamsDto(UuidParamsDto); + await middleware( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).not.toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(400); + }); + }); + + describe("validateQueryDto", () => { + it("should pass validation with valid pagination query", async () => { + mockRequest.query = { + page: "1", + limit: "10", + search: "test", + }; + + const middleware = validateQueryDto(PaginationQueryDto); + await middleware( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it("should fail validation with invalid pagination query", async () => { + mockRequest.query = { + page: "invalid", + limit: "-5", + }; + + const middleware = validateQueryDto(PaginationQueryDto); + await middleware( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).not.toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(400); + }); + }); +}); diff --git a/src/shared/middleware/validation.middleware.ts b/src/shared/middleware/validation.middleware.ts new file mode 100644 index 0000000..54fb7cc --- /dev/null +++ b/src/shared/middleware/validation.middleware.ts @@ -0,0 +1,127 @@ +import { Request, Response, NextFunction } from "express"; +import { validate, ValidationError } from "class-validator"; +import { plainToClass } from "class-transformer"; + +export interface ValidationErrorResponse { + success: false; + error: string; + details: Array<{ + property: string; + value: unknown; + constraints: string[]; + }>; +} + +export function validateDto(dtoClass: new () => T) { + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + try { + const dto = plainToClass(dtoClass, req.body); + const errors = await validate(dto); + + if (errors.length > 0) { + const errorResponse: ValidationErrorResponse = { + success: false, + error: "Validation failed", + details: errors.map((error: ValidationError) => ({ + property: error.property, + value: error.value, + constraints: error.constraints + ? Object.values(error.constraints) + : [], + })), + }; + + res.status(400).json(errorResponse); + return; + } + + req.body = dto; + next(); + } catch { + res.status(500).json({ + success: false, + error: "Internal server error during validation", + }); + } + }; +} + +export function validateQueryDto(dtoClass: new () => T) { + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + try { + const dto = plainToClass(dtoClass, req.query); + const errors = await validate(dto); + + if (errors.length > 0) { + const errorResponse: ValidationErrorResponse = { + success: false, + error: "Query validation failed", + details: errors.map((error: ValidationError) => ({ + property: error.property, + value: error.value, + constraints: error.constraints + ? Object.values(error.constraints) + : [], + })), + }; + + res.status(400).json(errorResponse); + return; + } + + req.query = dto as Record; + next(); + } catch { + res.status(500).json({ + success: false, + error: "Internal server error during query validation", + }); + } + }; +} + +export function validateParamsDto(dtoClass: new () => T) { + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + try { + const dto = plainToClass(dtoClass, req.params); + const errors = await validate(dto); + + if (errors.length > 0) { + const errorResponse: ValidationErrorResponse = { + success: false, + error: "Parameters validation failed", + details: errors.map((error: ValidationError) => ({ + property: error.property, + value: error.value, + constraints: error.constraints + ? Object.values(error.constraints) + : [], + })), + }; + + res.status(400).json(errorResponse); + return; + } + + req.params = dto as Record; + next(); + } catch { + res.status(500).json({ + success: false, + error: "Internal server error during parameter validation", + }); + } + }; +}