Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
34 changes: 34 additions & 0 deletions src/users/dto/profile-response.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
121 changes: 121 additions & 0 deletions src/users/dto/update-profile.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 15 additions & 6 deletions src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -200,6 +205,8 @@ export class UsersController {
});
}

// ─── Admin Verification ────────────────────────────────────────

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Post(':id/verify')
Expand Down Expand Up @@ -228,6 +235,8 @@ export class UsersController {
return this.usersService.reactivate(id, reactivateDto);
}

// ─── Preferences & Referrals ────────────────────────────────────

@UseGuards(JwtAuthGuard)
@Put('me/preferences')
updatePreferences(
Expand Down Expand Up @@ -281,4 +290,4 @@ export class UsersController {

return match[1];
}
}
}
116 changes: 116 additions & 0 deletions src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<<ProfileResponseDto> {
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<<ProfileResponseDto> {
// 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) {}
Expand Down