diff --git a/.env.example b/.env.example index 9912839..c2c2e1f 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,4 @@ DISCORD_WEBHOOK_URL= # Redis (required for rate limiting) REDIS_URL=redis://localhost:6379 + diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e8bb750..a06fe9b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { StellarModule } from './stellar/stellar.module'; import { SentryModule } from './sentry/sentry.module'; import { RedisModule } from './common/redis/redis.module'; import { RateLimitModule } from './common/rate-limit/rate-limit.module'; +import { UserProfileModule } from './user-profile/user-profile.module'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { RateLimitModule } from './common/rate-limit/rate-limit.module'; RedisModule, RateLimitModule, AuthModule, + UserProfileModule, EscrowModule, WebhookModule, MonitoringModule, diff --git a/backend/src/user-profile/index.ts b/backend/src/user-profile/index.ts new file mode 100644 index 0000000..9aaa1a2 --- /dev/null +++ b/backend/src/user-profile/index.ts @@ -0,0 +1,5 @@ +export * from './user-profile.entity'; +export * from './user-profile.dto'; +export * from './user-profile.service'; +export * from './user-profile.controller'; +export * from './user-profile.module'; diff --git a/backend/src/user-profile/user-profile.controller.ts b/backend/src/user-profile/user-profile.controller.ts new file mode 100644 index 0000000..be2de74 --- /dev/null +++ b/backend/src/user-profile/user-profile.controller.ts @@ -0,0 +1,380 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBody, + ApiQuery, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { UserProfileService } from './user-profile.service'; +import { + CreateUserProfileDto, + UpdateUserProfileDto, + RateUserDto, + CreateUserProfileSchema, + UpdateUserProfileSchema, + RateUserSchema, +} from './user-profile.dto'; +import { UserType, UserStatus } from './user-profile.entity'; +import { JwtAuthGuard } from '../auth/auth.guard'; + +@ApiTags('User Profiles') +@Controller('profiles') +export class UserProfileController { + constructor(private readonly userProfileService: UserProfileService) {} + + @Post() + @ApiOperation({ + summary: 'Create a new user profile', + description: + 'Creates a new user profile for a freelancer or client. Requires a unique Stellar wallet address.', + }) + @ApiBody({ + description: 'User profile creation details', + schema: { + type: 'object', + required: ['walletAddress', 'name', 'userType'], + properties: { + walletAddress: { + type: 'string', + description: 'Stellar wallet address (G-prefixed, 56 characters)', + example: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + }, + name: { + type: 'string', + description: 'User display name', + example: 'John Doe', + minLength: 2, + maxLength: 100, + }, + bio: { + type: 'string', + description: 'User biography', + example: 'Experienced blockchain developer with 5+ years in Web3', + maxLength: 500, + }, + userType: { + type: 'string', + enum: ['freelancer', 'client', 'both'], + description: 'Type of user account', + example: 'freelancer', + }, + avatarUrl: { + type: 'string', + description: 'Profile image URL', + example: 'https://example.com/avatar.jpg', + }, + email: { + type: 'string', + description: 'Email address (optional)', + example: 'john@example.com', + }, + skills: { + type: 'array', + items: { type: 'string' }, + description: 'List of skills (for freelancers)', + example: ['Solidity', 'Rust', 'TypeScript', 'Smart Contracts'], + maxItems: 20, + }, + socialLinks: { + type: 'object', + properties: { + twitter: { type: 'string', example: 'https://twitter.com/johndoe' }, + github: { type: 'string', example: 'https://github.com/johndoe' }, + linkedin: { type: 'string', example: 'https://linkedin.com/in/johndoe' }, + website: { type: 'string', example: 'https://johndoe.dev' }, + }, + }, + }, + }, + }) + @ApiResponse({ + status: 201, + description: 'Profile created successfully', + schema: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + walletAddress: { type: 'string' }, + name: { type: 'string' }, + bio: { type: 'string', nullable: true }, + userType: { type: 'string', enum: ['freelancer', 'client', 'both'] }, + avatarUrl: { type: 'string', nullable: true }, + email: { type: 'string', nullable: true }, + rating: { type: 'number', example: 0 }, + ratingCount: { type: 'number', example: 0 }, + completedJobs: { type: 'number', example: 0 }, + status: { type: 'string', enum: ['active', 'inactive', 'suspended'] }, + skills: { type: 'array', items: { type: 'string' }, nullable: true }, + socialLinks: { type: 'object', nullable: true }, + totalEarned: { type: 'string', example: '0' }, + totalSpent: { type: 'string', example: '0' }, + isVerified: { type: 'boolean', example: false }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + lastActiveAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid input data' }) + @ApiResponse({ status: 409, description: 'Profile with this wallet address already exists' }) + async create(@Body() dto: CreateUserProfileDto) { + const validated = CreateUserProfileSchema.parse(dto); + return this.userProfileService.create(validated); + } + + @Get() + @ApiOperation({ + summary: 'Get all user profiles', + description: 'Retrieves all user profiles with optional filtering.', + }) + @ApiQuery({ + name: 'userType', + required: false, + enum: UserType, + description: 'Filter by user type', + }) + @ApiQuery({ + name: 'status', + required: false, + enum: UserStatus, + description: 'Filter by status', + }) + @ApiQuery({ + name: 'minRating', + required: false, + type: Number, + description: 'Minimum rating filter', + }) + @ApiResponse({ + status: 200, + description: 'List of user profiles', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + walletAddress: { type: 'string' }, + name: { type: 'string' }, + userType: { type: 'string' }, + rating: { type: 'number' }, + completedJobs: { type: 'number' }, + isVerified: { type: 'boolean' }, + }, + }, + }, + }) + async findAll( + @Query('userType') userType?: UserType, + @Query('status') status?: UserStatus, + @Query('minRating') minRating?: number, + ) { + return this.userProfileService.findAll({ + userType, + status, + minRating: minRating ? parseFloat(minRating.toString()) : undefined, + }); + } + + @Get('search') + @ApiOperation({ + summary: 'Search user profiles', + description: 'Search profiles by name, bio, or skills.', + }) + @ApiQuery({ + name: 'q', + required: true, + type: String, + description: 'Search query', + example: 'blockchain developer', + }) + @ApiResponse({ + status: 200, + description: 'Search results', + }) + async search(@Query('q') query: string) { + return this.userProfileService.search(query); + } + + @Get(':id') + @ApiOperation({ + summary: 'Get profile by ID', + description: 'Retrieves a user profile by its unique ID.', + }) + @ApiParam({ + name: 'id', + description: 'Profile ID (UUID)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiResponse({ + status: 200, + description: 'Profile details', + }) + @ApiResponse({ status: 404, description: 'Profile not found' }) + async findById(@Param('id') id: string) { + return this.userProfileService.findById(id); + } + + @Get('wallet/:address') + @ApiOperation({ + summary: 'Get profile by wallet address', + description: 'Retrieves a user profile by their Stellar wallet address.', + }) + @ApiParam({ + name: 'address', + description: 'Stellar wallet address', + example: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + }) + @ApiResponse({ + status: 200, + description: 'Profile details', + }) + @ApiResponse({ status: 404, description: 'Profile not found' }) + async findByWalletAddress(@Param('address') address: string) { + return this.userProfileService.findByWalletAddress(address); + } + + @Put(':id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update user profile', + description: 'Updates an existing user profile. Requires authentication.', + }) + @ApiParam({ + name: 'id', + description: 'Profile ID', + }) + @ApiBody({ + description: 'Profile update fields', + schema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 2, maxLength: 100 }, + bio: { type: 'string', maxLength: 500 }, + userType: { type: 'string', enum: ['freelancer', 'client', 'both'] }, + avatarUrl: { type: 'string' }, + email: { type: 'string' }, + skills: { type: 'array', items: { type: 'string' } }, + socialLinks: { type: 'object' }, + status: { type: 'string', enum: ['active', 'inactive', 'suspended'] }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: 'Profile updated successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Profile not found' }) + async update(@Param('id') id: string, @Body() dto: UpdateUserProfileDto) { + const validated = UpdateUserProfileSchema.parse(dto); + return this.userProfileService.update(id, validated); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete user profile', + description: 'Deletes a user profile. Requires authentication.', + }) + @ApiParam({ + name: 'id', + description: 'Profile ID', + }) + @ApiResponse({ + status: 204, + description: 'Profile deleted successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Profile not found' }) + async delete(@Param('id') id: string) { + await this.userProfileService.delete(id); + } + + @Post(':id/rate') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Rate a user', + description: 'Submit a rating for a user profile. Requires authentication.', + }) + @ApiParam({ + name: 'id', + description: 'Profile ID to rate', + }) + @ApiBody({ + description: 'Rating details', + schema: { + type: 'object', + required: ['walletAddress', 'rating'], + properties: { + walletAddress: { + type: 'string', + description: 'Wallet address of the rater', + }, + rating: { + type: 'number', + minimum: 1, + maximum: 5, + description: 'Rating value (1-5)', + example: 5, + }, + review: { + type: 'string', + maxLength: 1000, + description: 'Optional review text', + example: 'Excellent work, delivered on time!', + }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: 'Rating submitted successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Profile not found' }) + async rateUser(@Param('id') id: string, @Body() dto: RateUserDto) { + const validated = RateUserSchema.parse(dto); + return this.userProfileService.rateUser(id, validated); + } + + @Post(':id/verify') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Verify a user profile', + description: 'Marks a user profile as verified. Admin only.', + }) + @ApiParam({ + name: 'id', + description: 'Profile ID', + }) + @ApiResponse({ + status: 200, + description: 'Profile verified successfully', + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 404, description: 'Profile not found' }) + async verifyUser(@Param('id') id: string) { + return this.userProfileService.verifyUser(id); + } +} diff --git a/backend/src/user-profile/user-profile.dto.ts b/backend/src/user-profile/user-profile.dto.ts new file mode 100644 index 0000000..787ffa2 --- /dev/null +++ b/backend/src/user-profile/user-profile.dto.ts @@ -0,0 +1,113 @@ +import { z } from 'zod'; +import { UserType, UserStatus } from './user-profile.entity'; + +/** + * Stellar address validation regex + * G-prefixed, 56 characters total + */ +const STELLAR_ADDRESS_REGEX = /^G[A-Z2-7]{55}$/; + +/** + * Email validation regex + */ +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * URL validation regex + */ +const URL_REGEX = /^https?:\/\/.+/; + +/** + * Schema for creating a new user profile + */ +export const CreateUserProfileSchema = z.object({ + walletAddress: z.string().regex(STELLAR_ADDRESS_REGEX, 'Invalid Stellar wallet address'), + name: z + .string() + .min(2, 'Name must be at least 2 characters') + .max(100, 'Name must not exceed 100 characters'), + bio: z.string().max(500, 'Bio must not exceed 500 characters').optional(), + userType: z.nativeEnum(UserType), + avatarUrl: z.string().regex(URL_REGEX, 'Invalid avatar URL').optional(), + email: z.string().regex(EMAIL_REGEX, 'Invalid email address').optional(), + skills: z.array(z.string().max(50)).max(20, 'Maximum 20 skills allowed').optional(), + socialLinks: z + .object({ + twitter: z.string().regex(URL_REGEX).optional(), + github: z.string().regex(URL_REGEX).optional(), + linkedin: z.string().regex(URL_REGEX).optional(), + website: z.string().regex(URL_REGEX).optional(), + }) + .optional(), +}); + +export type CreateUserProfileDto = z.infer; + +/** + * Schema for updating an existing user profile + * All fields are optional except walletAddress for identification + */ +export const UpdateUserProfileSchema = z.object({ + name: z + .string() + .min(2, 'Name must be at least 2 characters') + .max(100, 'Name must not exceed 100 characters') + .optional(), + bio: z.string().max(500, 'Bio must not exceed 500 characters').optional(), + userType: z.nativeEnum(UserType).optional(), + avatarUrl: z.string().regex(URL_REGEX, 'Invalid avatar URL').optional(), + email: z.string().regex(EMAIL_REGEX, 'Invalid email address').optional(), + skills: z.array(z.string().max(50)).max(20, 'Maximum 20 skills allowed').optional(), + socialLinks: z + .object({ + twitter: z.string().regex(URL_REGEX).optional(), + github: z.string().regex(URL_REGEX).optional(), + linkedin: z.string().regex(URL_REGEX).optional(), + website: z.string().regex(URL_REGEX).optional(), + }) + .optional(), + status: z.nativeEnum(UserStatus).optional(), +}); + +export type UpdateUserProfileDto = z.infer; + +/** + * Schema for rating a user + */ +export const RateUserSchema = z.object({ + walletAddress: z.string().regex(STELLAR_ADDRESS_REGEX, 'Invalid Stellar wallet address'), + rating: z.number().min(1, 'Rating must be at least 1').max(5, 'Rating must not exceed 5'), + review: z.string().max(1000, 'Review must not exceed 1000 characters').optional(), +}); + +export type RateUserDto = z.infer; + +/** + * Response DTO for user profile + * Excludes sensitive internal fields + */ +export interface UserProfileResponseDto { + id: string; + walletAddress: string; + name: string; + bio?: string; + userType: UserType; + avatarUrl?: string; + rating: number; + ratingCount: number; + completedJobs: number; + status: UserStatus; + skills?: string[]; + socialLinks?: { + twitter?: string; + github?: string; + linkedin?: string; + website?: string; + }; + totalEarned?: string; + totalSpent?: string; + isVerified: boolean; + createdAt: string; + updatedAt: string; + lastActiveAt?: string; +} diff --git a/backend/src/user-profile/user-profile.entity.ts b/backend/src/user-profile/user-profile.entity.ts new file mode 100644 index 0000000..9d3f5f2 --- /dev/null +++ b/backend/src/user-profile/user-profile.entity.ts @@ -0,0 +1,120 @@ +/** + * User Profile Entity + * Represents both freelancers and clients in the TrustFlow platform. + * Each profile is uniquely identified by their Stellar wallet address. + */ + +export enum UserType { + FREELANCER = 'freelancer', + CLIENT = 'client', + BOTH = 'both', +} + +export enum UserStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', +} + +export class UserProfileEntity { + /** + * Unique identifier (UUID) + */ + id: string; + + /** + * Stellar wallet address (G-prefixed, 56 characters) + * Serves as the primary authentication identifier + */ + walletAddress: string; + + /** + * Display name for the user + */ + name: string; + + /** + * User biography/description + */ + bio?: string; + + /** + * Type of user (freelancer, client, or both) + */ + userType: UserType; + + /** + * Profile image URL + */ + avatarUrl?: string; + + /** + * User's email address (optional) + */ + email?: string; + + /** + * Overall rating (0-5 scale) + */ + rating: number; + + /** + * Total number of ratings received + */ + ratingCount: number; + + /** + * Number of completed jobs/contracts + */ + completedJobs: number; + + /** + * Account status + */ + status: UserStatus; + + /** + * Skills or expertise tags (for freelancers) + */ + skills?: string[]; + + /** + * Social media links + */ + socialLinks?: { + twitter?: string; + github?: string; + linkedin?: string; + website?: string; + }; + + /** + * Total amount earned (in XLM) - for freelancers + */ + totalEarned?: string; + + /** + * Total amount spent (in XLM) - for clients + */ + totalSpent?: string; + + /** + * User verification status + */ + isVerified: boolean; + + /** + * Timestamp when the profile was created + */ + createdAt: Date; + + /** + * Timestamp when the profile was last updated + */ + updatedAt: Date; + + /** + * Timestamp of last activity + */ + lastActiveAt?: Date; +} diff --git a/backend/src/user-profile/user-profile.module.ts b/backend/src/user-profile/user-profile.module.ts new file mode 100644 index 0000000..bf92a08 --- /dev/null +++ b/backend/src/user-profile/user-profile.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UserProfileController } from './user-profile.controller'; +import { UserProfileService } from './user-profile.service'; + +@Module({ + controllers: [UserProfileController], + providers: [UserProfileService], + exports: [UserProfileService], +}) +export class UserProfileModule {} diff --git a/backend/src/user-profile/user-profile.service.spec.ts b/backend/src/user-profile/user-profile.service.spec.ts new file mode 100644 index 0000000..d94cb69 --- /dev/null +++ b/backend/src/user-profile/user-profile.service.spec.ts @@ -0,0 +1,332 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, ConflictException } from '@nestjs/common'; +import { UserProfileService } from './user-profile.service'; +import { UserType, UserStatus } from './user-profile.entity'; + +describe('UserProfileService', () => { + let service: UserProfileService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UserProfileService], + }).compile(); + + service = module.get(UserProfileService); + }); + + afterEach(() => { + // Clear all profiles after each test + service['profiles'].clear(); + service['walletAddressIndex'].clear(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + const validDto = { + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + bio: 'Experienced developer', + userType: UserType.FREELANCER, + skills: ['JavaScript', 'TypeScript'], + }; + + it('should create a new user profile', async () => { + const profile = await service.create(validDto); + + expect(profile).toBeDefined(); + expect(profile.id).toBeDefined(); + expect(profile.walletAddress).toBe(validDto.walletAddress); + expect(profile.name).toBe(validDto.name); + expect(profile.bio).toBe(validDto.bio); + expect(profile.userType).toBe(validDto.userType); + expect(profile.skills).toEqual(validDto.skills); + expect(profile.rating).toBe(0); + expect(profile.ratingCount).toBe(0); + expect(profile.completedJobs).toBe(0); + expect(profile.status).toBe(UserStatus.ACTIVE); + expect(profile.isVerified).toBe(false); + expect(profile.createdAt).toBeDefined(); + expect(profile.updatedAt).toBeDefined(); + }); + + it('should throw ConflictException for duplicate wallet address', async () => { + await service.create(validDto); + + await expect(service.create(validDto)).rejects.toThrow(ConflictException); + }); + }); + + describe('findById', () => { + it('should find a profile by ID', async () => { + const created = await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + userType: UserType.FREELANCER, + }); + + const found = await service.findById(created.id); + + expect(found).toBeDefined(); + expect(found.id).toBe(created.id); + expect(found.name).toBe(created.name); + }); + + it('should throw NotFoundException for non-existent ID', async () => { + await expect(service.findById('non-existent-id')).rejects.toThrow(NotFoundException); + }); + }); + + describe('findByWalletAddress', () => { + it('should find a profile by wallet address', async () => { + const walletAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + const created = await service.create({ + walletAddress, + name: 'John Doe', + userType: UserType.FREELANCER, + }); + + const found = await service.findByWalletAddress(walletAddress); + + expect(found).toBeDefined(); + expect(found.id).toBe(created.id); + expect(found.walletAddress).toBe(walletAddress); + }); + + it('should throw NotFoundException for non-existent wallet address', async () => { + await expect(service.findByWalletAddress('GYYYYYYYYYYYYYYYYYY')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('findAll', () => { + beforeEach(async () => { + await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'Freelancer 1', + userType: UserType.FREELANCER, + }); + await service.create({ + walletAddress: 'GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY', + name: 'Client 1', + userType: UserType.CLIENT, + }); + await service.create({ + walletAddress: 'GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ', + name: 'Both 1', + userType: UserType.BOTH, + }); + }); + + it('should return all profiles without filters', async () => { + const profiles = await service.findAll(); + expect(profiles).toHaveLength(3); + }); + + it('should filter by userType', async () => { + const profiles = await service.findAll({ userType: UserType.FREELANCER }); + expect(profiles.length).toBeGreaterThanOrEqual(1); + expect( + profiles.every(p => p.userType === UserType.FREELANCER || p.userType === UserType.BOTH), + ).toBe(true); + }); + + it('should filter by status', async () => { + const profiles = await service.findAll({ status: UserStatus.ACTIVE }); + expect(profiles).toHaveLength(3); + }); + + it('should filter by minRating', async () => { + const profiles = await service.findAll({ minRating: 0 }); + expect(profiles).toHaveLength(3); + }); + }); + + describe('update', () => { + it('should update a profile', async () => { + const created = await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + userType: UserType.FREELANCER, + }); + + // Small delay to ensure timestamps are different + await new Promise(resolve => setTimeout(resolve, 10)); + + const updated = await service.update(created.id, { + name: 'Jane Doe', + bio: 'Updated bio', + }); + + expect(updated.name).toBe('Jane Doe'); + expect(updated.bio).toBe('Updated bio'); + expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual( + new Date(created.updatedAt).getTime(), + ); + }); + + it('should throw NotFoundException for non-existent profile', async () => { + await expect(service.update('non-existent-id', { name: 'New Name' })).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('delete', () => { + it('should delete a profile', async () => { + const created = await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + userType: UserType.FREELANCER, + }); + + await service.delete(created.id); + + await expect(service.findById(created.id)).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException for non-existent profile', async () => { + await expect(service.delete('non-existent-id')).rejects.toThrow(NotFoundException); + }); + }); + + describe('rateUser', () => { + it('should add a rating to a user profile', async () => { + const created = await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + userType: UserType.FREELANCER, + }); + + const rated = await service.rateUser(created.id, { + walletAddress: 'GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY', + rating: 5, + }); + + expect(rated.rating).toBe(5); + expect(rated.ratingCount).toBe(1); + }); + + it('should calculate weighted average for multiple ratings', async () => { + const created = await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + userType: UserType.FREELANCER, + }); + + await service.rateUser(created.id, { + walletAddress: 'GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY', + rating: 5, + }); + + const rated = await service.rateUser(created.id, { + walletAddress: 'GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ', + rating: 3, + }); + + expect(rated.rating).toBe(4); // (5 + 3) / 2 = 4 + expect(rated.ratingCount).toBe(2); + }); + }); + + describe('incrementCompletedJobs', () => { + it('should increment completed jobs counter', async () => { + const created = await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + userType: UserType.FREELANCER, + }); + + const updated = await service.incrementCompletedJobs(created.id); + + expect(updated.completedJobs).toBe(1); + }); + }); + + describe('updateTotalEarned', () => { + it('should update total earned amount', async () => { + const created = await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + userType: UserType.FREELANCER, + }); + + const updated = await service.updateTotalEarned(created.id, '100.5'); + + expect(updated.totalEarned).toBe('100.5000000'); + }); + }); + + describe('updateTotalSpent', () => { + it('should update total spent amount', async () => { + const created = await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + userType: UserType.CLIENT, + }); + + const updated = await service.updateTotalSpent(created.id, '200.75'); + + expect(updated.totalSpent).toBe('200.7500000'); + }); + }); + + describe('verifyUser', () => { + it('should verify a user profile', async () => { + const created = await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'John Doe', + userType: UserType.FREELANCER, + }); + + const verified = await service.verifyUser(created.id); + + expect(verified.isVerified).toBe(true); + }); + }); + + describe('search', () => { + beforeEach(async () => { + await service.create({ + walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + name: 'Blockchain Developer', + bio: 'Experienced in Solidity', + userType: UserType.FREELANCER, + skills: ['Solidity', 'Rust'], + }); + await service.create({ + walletAddress: 'GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY', + name: 'Frontend Developer', + bio: 'React expert', + userType: UserType.FREELANCER, + skills: ['React', 'TypeScript'], + }); + }); + + it('should search by name', async () => { + const results = await service.search('blockchain'); + expect(results).toHaveLength(1); + expect(results[0].name).toContain('Blockchain'); + }); + + it('should search by bio', async () => { + const results = await service.search('solidity'); + expect(results).toHaveLength(1); + expect(results[0].bio).toContain('Solidity'); + }); + + it('should search by skills', async () => { + const results = await service.search('rust'); + expect(results).toHaveLength(1); + expect(results[0].skills).toContain('Rust'); + }); + + it('should return empty array for no matches', async () => { + const results = await service.search('nonexistent'); + expect(results).toHaveLength(0); + }); + }); +}); diff --git a/backend/src/user-profile/user-profile.service.ts b/backend/src/user-profile/user-profile.service.ts new file mode 100644 index 0000000..1f4ddba --- /dev/null +++ b/backend/src/user-profile/user-profile.service.ts @@ -0,0 +1,242 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { UserType, UserStatus } from './user-profile.entity'; +import { CreateUserProfileDto, UpdateUserProfileDto, RateUserDto } from './user-profile.dto'; +import { randomUUID } from 'crypto'; + +export interface UserProfile { + id: string; + walletAddress: string; + name: string; + bio?: string; + userType: UserType; + avatarUrl?: string; + email?: string; + rating: number; + ratingCount: number; + completedJobs: number; + status: UserStatus; + skills?: string[]; + socialLinks?: { + twitter?: string; + github?: string; + linkedin?: string; + website?: string; + }; + totalEarned?: string; + totalSpent?: string; + isVerified: boolean; + createdAt: string; + updatedAt: string; + lastActiveAt?: string; +} + +@Injectable() +export class UserProfileService { + private profiles: Map = new Map(); + private walletAddressIndex: Map = new Map(); // walletAddress -> profileId + + /** + * Create a new user profile + */ + async create(dto: CreateUserProfileDto): Promise { + // Check if wallet address already exists + if (this.walletAddressIndex.has(dto.walletAddress)) { + throw new ConflictException('Profile with this wallet address already exists'); + } + + const id = randomUUID(); + const now = new Date().toISOString(); + + const profile: UserProfile = { + id, + walletAddress: dto.walletAddress, + name: dto.name, + bio: dto.bio, + userType: dto.userType, + avatarUrl: dto.avatarUrl, + email: dto.email, + rating: 0, + ratingCount: 0, + completedJobs: 0, + status: UserStatus.ACTIVE, + skills: dto.skills, + socialLinks: dto.socialLinks, + totalEarned: '0', + totalSpent: '0', + isVerified: false, + createdAt: now, + updatedAt: now, + lastActiveAt: now, + }; + + this.profiles.set(id, profile); + this.walletAddressIndex.set(dto.walletAddress, id); + + return profile; + } + + /** + * Find profile by ID + */ + async findById(id: string): Promise { + const profile = this.profiles.get(id); + if (!profile) { + throw new NotFoundException('User profile not found'); + } + return profile; + } + + /** + * Find profile by wallet address + */ + async findByWalletAddress(walletAddress: string): Promise { + const profileId = this.walletAddressIndex.get(walletAddress); + if (!profileId) { + throw new NotFoundException('User profile not found'); + } + return this.findById(profileId); + } + + /** + * Get all profiles with optional filters + */ + async findAll(filters?: { + userType?: UserType; + status?: UserStatus; + minRating?: number; + }): Promise { + let profiles = Array.from(this.profiles.values()); + + if (filters?.userType) { + profiles = profiles.filter( + p => p.userType === filters.userType || p.userType === UserType.BOTH, + ); + } + + if (filters?.status) { + profiles = profiles.filter(p => p.status === filters.status); + } + + if (filters?.minRating !== undefined) { + profiles = profiles.filter(p => p.rating >= (filters.minRating ?? 0)); + } + + return profiles; + } + + /** + * Update a user profile + */ + async update(id: string, dto: UpdateUserProfileDto): Promise { + const profile = await this.findById(id); + + // Update fields + if (dto.name !== undefined) profile.name = dto.name; + if (dto.bio !== undefined) profile.bio = dto.bio; + if (dto.userType !== undefined) profile.userType = dto.userType; + if (dto.avatarUrl !== undefined) profile.avatarUrl = dto.avatarUrl; + if (dto.email !== undefined) profile.email = dto.email; + if (dto.skills !== undefined) profile.skills = dto.skills; + if (dto.socialLinks !== undefined) { + profile.socialLinks = { ...profile.socialLinks, ...dto.socialLinks }; + } + if (dto.status !== undefined) profile.status = dto.status; + + profile.updatedAt = new Date().toISOString(); + + return profile; + } + + /** + * Delete a user profile + */ + async delete(id: string): Promise { + const profile = await this.findById(id); + this.walletAddressIndex.delete(profile.walletAddress); + this.profiles.delete(id); + } + + /** + * Rate a user profile + * Updates the overall rating using a weighted average + */ + async rateUser(id: string, dto: RateUserDto): Promise { + const profile = await this.findById(id); + + // Calculate new rating using weighted average + const totalRating = profile.rating * profile.ratingCount; + const newRatingCount = profile.ratingCount + 1; + const newRating = (totalRating + dto.rating) / newRatingCount; + + profile.rating = Math.round(newRating * 100) / 100; // Round to 2 decimal places + profile.ratingCount = newRatingCount; + profile.updatedAt = new Date().toISOString(); + + return profile; + } + + /** + * Increment completed jobs counter + */ + async incrementCompletedJobs(id: string): Promise { + const profile = await this.findById(id); + profile.completedJobs += 1; + profile.updatedAt = new Date().toISOString(); + return profile; + } + + /** + * Update total earned (for freelancers) + */ + async updateTotalEarned(id: string, amount: string): Promise { + const profile = await this.findById(id); + const currentEarned = parseFloat(profile.totalEarned || '0'); + const additionalAmount = parseFloat(amount); + profile.totalEarned = (currentEarned + additionalAmount).toFixed(7); + profile.updatedAt = new Date().toISOString(); + return profile; + } + + /** + * Update total spent (for clients) + */ + async updateTotalSpent(id: string, amount: string): Promise { + const profile = await this.findById(id); + const currentSpent = parseFloat(profile.totalSpent || '0'); + const additionalAmount = parseFloat(amount); + profile.totalSpent = (currentSpent + additionalAmount).toFixed(7); + profile.updatedAt = new Date().toISOString(); + return profile; + } + + /** + * Verify a user profile + */ + async verifyUser(id: string): Promise { + const profile = await this.findById(id); + profile.isVerified = true; + profile.updatedAt = new Date().toISOString(); + return profile; + } + + /** + * Update last active timestamp + */ + async updateLastActive(id: string): Promise { + const profile = await this.findById(id); + profile.lastActiveAt = new Date().toISOString(); + } + + /** + * Search profiles by name or skills + */ + async search(query: string): Promise { + const lowerQuery = query.toLowerCase(); + return Array.from(this.profiles.values()).filter( + profile => + profile.name.toLowerCase().includes(lowerQuery) || + profile.bio?.toLowerCase().includes(lowerQuery) || + profile.skills?.some(skill => skill.toLowerCase().includes(lowerQuery)), + ); + } +}