diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 3b04e45..00958dd 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -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; @@ -39,9 +40,7 @@ type UploadedAvatarFile = { }; @ApiTags('Users') -@ApiBearerAuth('access-token') @Controller('users') -@UseGuards(JwtAuthGuard) @UseInterceptors(ClassSerializerInterceptor) export class UsersController { constructor( @@ -50,6 +49,8 @@ export class UsersController { ) {} @Get('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('access-token') @ApiOperation({ summary: 'Get current user profile' }) @ApiResponse({ status: 200, @@ -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' }) @@ -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' }) @@ -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' }) @@ -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' }) @@ -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' }) @@ -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' }) @@ -133,6 +146,8 @@ export class UsersController { } @Patch('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('access-token') @ApiOperation({ summary: 'Update user profile' }) @ApiResponse({ status: 200, @@ -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' }) @@ -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, @@ -181,6 +200,22 @@ 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( + @Param('username') username: string, + ): Promise { + return this.userService.findByUsername(username); + } + + // Public endpoint — no authentication required. @Get(':id/public') @ApiOperation({ summary: 'Get public user profile by ID' }) @ApiResponse({ @@ -188,15 +223,7 @@ export class UsersController { 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 { return this.userService.getPublicProfile(id); } } diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index ab476f1..4e3ead4 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -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'; @@ -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( @@ -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 { const user = await this.userRepository.findOne({ where: { id: userId } }); if (!user) { @@ -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 { 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 { + // 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) }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const badgesCount = await this.userBadgeRepository.count({ + where: { userId: user.id }, + }); + + return this.toPublicProfile(user, badgesCount); } async adminListUsers(