Skip to content
Open
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
49 changes: 38 additions & 11 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { UserService } from './users.service';
import { WalletService } from './wallet.service';
import { DeleteAccountDto } from './dto/delete-account.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { PublicProfileDto } from './users.service';

type UploadedAvatarFile = {
size: number;
Expand All @@ -39,9 +40,7 @@ type UploadedAvatarFile = {
};

@ApiTags('Users')
@ApiBearerAuth('access-token')
@Controller('users')
@UseGuards(JwtAuthGuard)
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
constructor(
Expand All @@ -50,6 +49,8 @@ export class UsersController {
) {}

@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({
status: 200,
Expand All @@ -64,6 +65,7 @@ export class UsersController {

@Get('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Get admin user data (admin only)' })
@ApiResponse({ status: 200, description: 'Admin data retrieved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
Expand All @@ -76,6 +78,8 @@ export class UsersController {
}

@Post('me/avatar')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@UseInterceptors(FileInterceptor('avatar'))
@ApiOperation({ summary: 'Upload user avatar' })
@ApiResponse({ status: 200, description: 'Avatar uploaded successfully' })
Expand All @@ -85,7 +89,10 @@ export class UsersController {
) {
return this.userService.uploadAvatar(req.user.id as string, file);
}

@Post('me/wallet/challenge')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Generate wallet verification challenge' })
@ApiResponse({ status: 200, description: 'Challenge generated successfully' })
Expand All @@ -97,6 +104,8 @@ export class UsersController {
}

@Post('me/wallet/verify')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify and link wallet to user account' })
@ApiResponse({ status: 200, description: 'Wallet linked successfully' })
Expand All @@ -114,6 +123,8 @@ export class UsersController {
}

@Get('me/wallet')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Get wallet connection status' })
@ApiResponse({ status: 200, description: 'Wallet status retrieved' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
Expand All @@ -124,6 +135,8 @@ export class UsersController {
}

@Delete('me/wallet')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Unlink wallet from user account' })
@ApiResponse({ status: 204, description: 'Wallet unlinked successfully' })
Expand All @@ -133,6 +146,8 @@ export class UsersController {
}

@Patch('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Update user profile' })
@ApiResponse({
status: 200,
Expand All @@ -149,6 +164,8 @@ export class UsersController {
}

@Delete('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete user account' })
@ApiResponse({ status: 204, description: 'Account deleted successfully' })
Expand All @@ -161,6 +178,8 @@ export class UsersController {
}

@Get('me/stats')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('access-token')
@ApiOperation({ summary: 'Get user statistics and achievements' })
@ApiResponse({
status: 200,
Expand All @@ -181,22 +200,30 @@ export class UsersController {
return this.userService.getMyStats(req.user.id as string);
}

// Public endpoint — no authentication required.
// username is not DB-UNIQUE yet; if duplicates exist the first row is returned.
@Get('by-username/:username')
@ApiOperation({ summary: 'Get public profile by username' })
@ApiResponse({
status: 200,
description: 'Public profile retrieved successfully',
})
@ApiResponse({ status: 404, description: 'User not found' })
async getPublicProfileByUsername(
Comment on lines +205 to +212
@Param('username') username: string,
Comment on lines +205 to +213
): Promise<PublicProfileDto> {
return this.userService.findByUsername(username);
}

// Public endpoint — no authentication required.
@Get(':id/public')
@ApiOperation({ summary: 'Get public user profile by ID' })
@ApiResponse({
status: 200,
description: 'Public profile retrieved successfully',
})
@ApiResponse({ status: 404, description: 'User not found' })
async getPublicProfile(@Param('id') id: string): Promise<{
id: string;
username: string | null;
xp: number;
badgesCount: number;
coursesCompleted: number;
avatarUrl: string | null;
bio: string | null;
}> {
async getPublicProfile(@Param('id') id: string): Promise<PublicProfileDto> {
return this.userService.getPublicProfile(id);
}
}
68 changes: 49 additions & 19 deletions backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan, Like } from 'typeorm';
import { ILike, Repository, MoreThan, Like } from 'typeorm';
import * as crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { RegisterDto } from '../auth/dto/register.dto';
Expand All @@ -27,6 +27,16 @@ type AvatarUploadFile = {
buffer: Buffer;
};

export type PublicProfileDto = {
id: string;
username: string | null;
xp: number;
badgesCount: number;
coursesCompleted: number;
avatarUrl: string | null;
bio: string | null;
};

@Injectable()
export class UserService {
constructor(
Expand Down Expand Up @@ -136,6 +146,23 @@ export class UserService {
return user.points ?? 0;
}

/**
* Maps a User entity and its badge count to the public profile shape.
* Centralises the mapping so that getPublicProfile() and findByUsername()
* stay in sync without duplicating the field list.
*/
private toPublicProfile(user: User, badgesCount: number): PublicProfileDto {
return {
id: user.id,
username: user.username ?? user.name ?? null,
xp: this.resolveXp(user),
badgesCount,
coursesCompleted: user.coursesCompleted ?? 0,
avatarUrl: user.avatarUrl ?? null,
bio: user.bio ?? null,
};
}

async getProfile(userId: string): Promise<User> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
Expand Down Expand Up @@ -310,29 +337,32 @@ export class UserService {
};
}

async getPublicProfile(userId: string): Promise<{
id: string;
username: string | null;
xp: number;
badgesCount: number;
coursesCompleted: number;
avatarUrl: string | null;
bio: string | null;
}> {
async getPublicProfile(userId: string): Promise<PublicProfileDto> {
const user = await this.getProfile(userId);
const badgesCount = await this.userBadgeRepository.count({
where: { userId },
});

return {
id: user.id,
username: user.username ?? user.name ?? null,
xp: this.resolveXp(user),
badgesCount,
coursesCompleted: user.coursesCompleted ?? 0,
avatarUrl: user.avatarUrl ?? null,
bio: user.bio ?? null,
};
return this.toPublicProfile(user, badgesCount);
}

async findByUsername(username: string): Promise<PublicProfileDto> {
// username is not enforced as UNIQUE at the DB level; findOne returns the
// first match when duplicates exist. This is a known limitation — a future
// migration should add a UNIQUE constraint on the username column.
const user = await this.userRepository.findOne({
where: { username: ILike(username) },
});
Comment on lines +353 to +355

if (!user) {
throw new NotFoundException('User not found');
}

Comment on lines +353 to +360
const badgesCount = await this.userBadgeRepository.count({
where: { userId: user.id },
});

return this.toPublicProfile(user, badgesCount);
}

async adminListUsers(
Expand Down
Loading