diff --git a/src/main.ts b/src/main.ts index a7cb98ff..957358d5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import { RateLimitGuard } from './auth/guards/rate-limit.guard'; import { RateLimitService } from './auth/rate-limit.service'; import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor'; import { setupSwagger } from './config/swagger.config'; +import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -50,6 +51,17 @@ async function bootstrap() { const cacheMonitoringService = app.get(CacheMonitoringService); app.useGlobalInterceptors(new CacheMetricsInterceptor(cacheMonitoringService)); + app.useGlobalPipe( + new ValidationPipe({ + whitelist: true, // Strip properties not in DTO + forbidNonWhitelisted: true, // Throw error for extra properties + transform: true, // Auto-transform types + transformOptions: { + enableImplicitConversion: true, + }, + }), +); + // Setup Swagger documentation setupSwagger(app); diff --git a/src/users/dto/profile-response.dto.ts b/src/users/dto/profile-response.dto.ts new file mode 100644 index 00000000..b6e592ec --- /dev/null +++ b/src/users/dto/profile-response.dto.ts @@ -0,0 +1,34 @@ +export class ProfileResponseDto { + id: string; + email: string; + firstName: string; + lastName: string; + fullName: string; + phone: string | null; + avatar: string | null; + bio: string | null; + role: string; + isVerified: boolean; + preferredChannel: string | null; + languagePreference: string | null; + timezone: string | null; + contactHours: { start: string; end: string } | null; + address: { + street?: string; + city?: string; + state?: string; + zipCode?: string; + country?: string; + } | null; + occupation: string | null; + company: string | null; + referralCode: string | null; + createdAt: Date; + updatedAt: Date; + lastActivityAt: Date | null; + statistics: { + propertiesCount: number; + transactionsCount: number; + accountAgeDays: number; + } | null; +} \ No newline at end of file diff --git a/src/users/dto/update-profile.dto.ts b/src/users/dto/update-profile.dto.ts new file mode 100644 index 00000000..fbd9ed12 --- /dev/null +++ b/src/users/dto/update-profile.dto.ts @@ -0,0 +1,121 @@ +import { + IsEmail, + IsOptional, + IsString, + MinLength, + MaxLength, + IsIn, + IsObject, + IsUrl, + ValidateNested, + IsPhoneNumber, + IsISO31661Alpha2, + Matches, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +class ContactHoursDto { + @IsString() + @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { + message: 'Start time must be in HH:MM format (24-hour)', + }) + start: string; + + @IsString() + @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { + message: 'End time must be in HH:MM format (24-hour)', + }) + end: string; +} + +class AddressDto { + @IsOptional() + @IsString() + @MaxLength(200) + street?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + zipCode?: string; + + @IsOptional() + @IsISO31661Alpha2() + country?: string; +} + +export class UpdateProfileDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(50) + firstName?: string; + + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(50) + lastName?: string; + + @IsOptional() + @IsEmail({}, { message: 'Please provide a valid email address' }) + email?: string; + + @IsOptional() + @IsPhoneNumber(undefined, { message: 'Please provide a valid phone number' }) + phone?: string; + + @IsOptional() + @IsUrl({}, { message: 'Avatar must be a valid URL' }) + @MaxLength(500) + avatar?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + bio?: string; + + @IsOptional() + @IsIn(['email', 'sms', 'phone', 'push']) + preferredChannel?: string; + + @IsOptional() + @IsString() + @IsIn(['en', 'es', 'fr', 'de', 'zh', 'ja', 'ar']) + languagePreference?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + timezone?: string; + + @IsOptional() + @ValidateNested() + @Type(() => ContactHoursDto) + contactHours?: ContactHoursDto; + + @IsOptional() + @ValidateNested() + @Type(() => AddressDto) + address?: AddressDto; + + @IsOptional() + @IsString() + @MaxLength(100) + occupation?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + company?: string; +} \ No newline at end of file diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 9e07ce42..47346d54 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -33,11 +33,14 @@ import { UpdateUserProfileDto, } from './dto/user.dto'; import { DeactivateAccountDto, ReactivateAccountDto } from './dto/deactivation.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} + // ─── Admin Endpoints ───────────────────────────────────────────── + @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post() @@ -100,23 +103,25 @@ export class UsersController { return this.usersService.remove(id); } - // User profile management + // ─── Profile Management (#306) ─────────────────────────────────── + @UseGuards(JwtAuthGuard) @Get('me/profile') getProfile(@CurrentUser() user: AuthUserPayload) { - return this.usersService.findOne(user.sub); + return this.usersService.getProfile(user.sub); } @UseGuards(JwtAuthGuard) @Put('me/profile') updateProfile( @CurrentUser() user: AuthUserPayload, - @Body() updateProfileDto: UpdateUserProfileDto, + @Body() updateProfileDto: UpdateProfileDto, ) { - return this.usersService.update(user.sub, updateProfileDto); + return this.usersService.updateProfile(user.sub, updateProfileDto); } - // User self-service deactivation + // ─── User Self-Service ─────────────────────────────────────────── + @UseGuards(JwtAuthGuard) @Post(':id/export') async exportData(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { @@ -200,6 +205,8 @@ export class UsersController { }); } + // ─── Admin Verification ──────────────────────────────────────── + @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post(':id/verify') @@ -228,6 +235,8 @@ export class UsersController { return this.usersService.reactivate(id, reactivateDto); } + // ─── Preferences & Referrals ──────────────────────────────────── + @UseGuards(JwtAuthGuard) @Put('me/preferences') updatePreferences( @@ -281,4 +290,4 @@ export class UsersController { return match[1]; } -} +} \ No newline at end of file diff --git a/src/users/users.service.ts b/src/users/users.service.ts index a7e9cd95..4756d108 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -5,9 +5,125 @@ import { DeactivateAccountDto, ReactivateAccountDto } from './dto/deactivation.d import { hashPassword, sanitizeUser } from '../auth/security.utils'; import * as fs from 'fs'; import * as path from 'path'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ProfileResponseDto } from './dto/profile-response.dto'; @Injectable() export class UsersService implements OnModuleInit { + async getProfile(userId: string): Promise< { + const user = await this.prisma.user.findUnique({ + where: { id: userId, isDeactivated: false }, + include: { + properties: { select: { id: true } }, + buyerTransactions: { select: { id: true } }, + sellerTransactions: { select: { id: true } }, + _count: { + select: { + properties: true, + buyerTransactions: true, + sellerTransactions: true, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundException('User profile not found'); + } + + const now = new Date(); + const createdAt = new Date(user.createdAt); + const accountAgeDays = Math.floor( + (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24), + ); + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + fullName: `${user.firstName} ${user.lastName}`, + phone: user.phone, + avatar: user.avatar, + bio: user.bio || null, // if bio field exists in schema, otherwise omit + role: user.role, + isVerified: user.isVerified, + preferredChannel: user.preferredChannel, + languagePreference: user.languagePreference, + timezone: user.timezone, + contactHours: user.contactHours as { start: string; end: string } | null, + address: user.address as any || null, // if address field exists + occupation: user.occupation || null, + company: user.company || null, + referralCode: user.referralCode, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastActivityAt: user.lastActivityAt, + statistics: { + propertiesCount: user._count.properties, + transactionsCount: user._count.buyerTransactions + user._count.sellerTransactions, + accountAgeDays, + }, + }; +} + +async updateProfile( + userId: string, + data: UpdateProfileDto, +): Promise< { + // Check if email is being changed and if it's already taken + if (data.email) { + const existingUser = await this.prisma.user.findFirst({ + where: { + email: data.email, + NOT: { id: userId }, + }, + }); + + if (existingUser) { + throw new BadRequestException('Email address is already in use'); + } + } + + // Build update data — only include provided fields + const updateData: any = {}; + + if (data.firstName !== undefined) updateData.firstName = data.firstName; + if (data.lastName !== undefined) updateData.lastName = data.lastName; + if (data.email !== undefined) updateData.email = data.email; + if (data.phone !== undefined) updateData.phone = data.phone; + if (data.avatar !== undefined) updateData.avatar = data.avatar; + if (data.bio !== undefined) updateData.bio = data.bio; + if (data.preferredChannel !== undefined) updateData.preferredChannel = data.preferredChannel; + if (data.languagePreference !== undefined) updateData.languagePreference = data.languagePreference; + if (data.timezone !== undefined) updateData.timezone = data.timezone; + if (data.contactHours !== undefined) updateData.contactHours = data.contactHours; + if (data.address !== undefined) updateData.address = data.address; + if (data.occupation !== undefined) updateData.occupation = data.occupation; + if (data.company !== undefined) updateData.company = data.company; + + // Update user + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + + // Log the profile update activity + await this.prisma.activityLog.create({ + data: { + userId, + action: 'UPDATE_PROFILE', + entityType: 'USER', + entityId: userId, + description: 'User updated their profile', + metadata: { updatedFields: Object.keys(updateData) }, + }, + }); + + // Return fresh profile + return this.getProfile(userId); +} + private readonly logger = new Logger(UsersService.name); constructor(private prisma: PrismaService) {}