diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 84392ed1..ed12007c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module, } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; @@ -26,25 +26,17 @@ import { TournamentModule } from './tournament/tournament.module'; import { GameGateway } from './websocket-game comms/providers/gamegateway'; import { GameModule } from './websocket-game comms/game.module'; import { AchievementModule } from './achievement/achievement.module'; - import { MusicTheoryLessonModule } from './music-education/music-theory-lesson.module'; - import { GameModeModule } from './game-mode/game-mode.module'; - import { SongGenreModule } from './song-genre/song-genre.module'; - import { SocialModule } from './social/social.module'; -// import { AchievementModule } from './achievement/achievement.module'; import { CacheModule } from '@nestjs/cache-manager'; import * as redisStore from 'cache-manager-redis-store'; import { ThrottlerModule } from '@nestjs/throttler'; -<<<<<<< HEAD import { ReferralModule } from './referral/referral.module'; -======= import { GameInsightsModule } from './game-insights/game-insights.module'; import { PaginationModule } from './common/pagination/pagination.module'; import { StateRecoveryModule } from './state-recovery/state-recovery.module'; ->>>>>>> 1254719ee782ca271ea231ebe706912a91061959 @Module({ imports: [ @@ -79,7 +71,7 @@ import { StateRecoveryModule } from './state-recovery/state-recovery.module'; host: process.env.REDIS_HOST || 'localhost', port: Number(process.env.REDIS_PORT) || 6379, }, - ttl: 3600, + ttl: 3600, }), SongsModule, ChatRoomModule, @@ -91,12 +83,9 @@ import { StateRecoveryModule } from './state-recovery/state-recovery.module'; MusicTheoryLessonModule, GameModeModule, SongGenreModule, -<<<<<<< HEAD ReferralModule, -StateRecoveryModule, -======= + StateRecoveryModule, GameInsightsModule, ->>>>>>> 1254719ee782ca271ea231ebe706912a91061959 ], controllers: [AppController], providers: [ diff --git a/backend/src/common/pagination/pagination-query-dto.dto.ts b/backend/src/common/pagination/pagination-query-dto.dto.ts new file mode 100644 index 00000000..434bffaf --- /dev/null +++ b/backend/src/common/pagination/pagination-query-dto.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsPositive } from 'class-validator'; + +import { Type } from 'class-transformer'; + +export class PaginationQueryDto { + // @Type(() => Number) //coverting strings to numbers + @IsOptional() + @IsPositive() + limit?: number = 10; + + @IsOptional() + @IsPositive() + page?: number = 1; +} diff --git a/backend/src/common/pagination/provider/pagination.provider.ts b/backend/src/common/pagination/provider/pagination.provider.ts index 98a54697..539f6de2 100644 --- a/backend/src/common/pagination/provider/pagination.provider.ts +++ b/backend/src/common/pagination/provider/pagination.provider.ts @@ -1,20 +1,20 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { PaginationQueryDto } from "src/common/nterceptors/data-response/pagination/pagination-query.dto"; -import { ObjectLiteral, Repository } from "typeorm"; -import { Request } from "express"; -import { REQUEST } from "@nestjs/core"; -import { paginated } from "../interfaces/pagination-interface"; +import { Injectable, Inject } from '@nestjs/common'; +import { ObjectLiteral, Repository } from 'typeorm'; +import { Request } from 'express'; +import { REQUEST } from '@nestjs/core'; +import { paginated } from '../interfaces/pagination-interface'; +import { PaginationQueryDto } from '../pagination-query-dto.dto'; @Injectable() export class PaginationProvider { constructor( @Inject(REQUEST) - private readonly request: Request + private readonly request: Request, ) {} public async paginationQuery( //type generic is a type that is used when we are unsure of the type of object to return paginatedQueryDto: PaginationQueryDto, - repository: Repository + repository: Repository, ): Promise> { const result = await repository.find({ skip: paginatedQueryDto.limit * (paginatedQueryDto.page - 1), @@ -24,7 +24,7 @@ export class PaginationProvider { //create a request url //create a variable called base url const baseUrl = - this.request.protocol + "://" + this.request.headers.host + "/"; + this.request.protocol + '://' + this.request.headers.host + '/'; console.log(baseUrl); const newUrl = new URL(this.request.url, baseUrl); diff --git a/backend/src/game-results/dto/get-gamesresult-base-dto.dto.ts b/backend/src/game-results/dto/get-gamesresult-base-dto.dto.ts new file mode 100644 index 00000000..7ab15564 --- /dev/null +++ b/backend/src/game-results/dto/get-gamesresult-base-dto.dto.ts @@ -0,0 +1,19 @@ +import { IsDate, IsOptional } from 'class-validator'; +import { IntersectionType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { PaginationQueryDto } from 'src/common/pagination/pagination-query-dto.dto'; + +class GetGamesResultBaseDto { + @IsDate() + @IsOptional() + startDate?: Date; + + @IsDate() + @IsOptional() + endDate?: Date; +} + +export class GetGamesResultsDto extends IntersectionType( + GetGamesResultBaseDto, + PaginationQueryDto, +) {} diff --git a/backend/src/game-results/game-results.controller.ts b/backend/src/game-results/game-results.controller.ts index 0b70a206..17624702 100644 --- a/backend/src/game-results/game-results.controller.ts +++ b/backend/src/game-results/game-results.controller.ts @@ -1,8 +1,19 @@ -import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + DefaultValuePipe, + ParseIntPipe, +} from '@nestjs/common'; import { GameResultsService } from './game-results.service'; import { CreateGameResultDto } from './dto/game-result.dto'; import { GameResult } from './entities/game-result.entity'; import { LeaderboardEntryDto } from './dto/leaderboard-entry.dto'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; // Assuming you have some form of authentication // import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -12,7 +23,9 @@ export class GameResultsController { @Post() // @UseGuards(JwtAuthGuard) - async createResult(@Body() createGameResultDto: CreateGameResultDto): Promise { + async createResult( + @Body() createGameResultDto: CreateGameResultDto, + ): Promise { return this.gameResultsService.createResult(createGameResultDto); } @@ -33,10 +46,17 @@ export class GameResultsController { return this.gameResultsService.getUserBest(userId, gameId); } - @Get('user/:userId') - // @UseGuards(JwtAuthGuard) - async getUserResults(@Param('userId') userId: string): Promise { - return this.gameResultsService.getUserResults(userId); + @Get('/user/:userId') + @ApiOperation({ summary: 'Get user results with pagination' }) + @ApiResponse({ + status: 200, + description: 'List of user results successfully retrieved', + }) + public getUserResults( + @Param('userId') userId: string, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + ): Promise { + return this.gameResultsService.getUserResults(userId, page, limit); } } - diff --git a/backend/src/game-results/game-results.service.ts b/backend/src/game-results/game-results.service.ts index 1aaa3f7e..76d912e2 100644 --- a/backend/src/game-results/game-results.service.ts +++ b/backend/src/game-results/game-results.service.ts @@ -24,17 +24,17 @@ export class GameResultsService { // Implement your scoring algorithm based on your game's logic // Example: Base score from game + time bonus + achievement bonus let finalScore = gameData.baseScore || 0; - + // Time bonus (example: faster completion = higher bonus) const timeBonus = Math.max(1000 - timeSpent, 0) * 0.1; finalScore += timeBonus; - + // Achievement bonus if (gameData.achievements && Array.isArray(gameData.achievements)) { const achievementBonus = gameData.achievements.length * 50; finalScore += achievementBonus; } - + // Round to integer return Math.round(finalScore); } @@ -42,7 +42,9 @@ export class GameResultsService { /** * Create and store a new game result */ - async createResult(createGameResultDto: CreateGameResultDto): Promise { + async createResult( + createGameResultDto: CreateGameResultDto, + ): Promise { // Calculate final score if not provided if (!createGameResultDto.score) { createGameResultDto.score = this.calculateFinalScore( @@ -50,11 +52,11 @@ export class GameResultsService { createGameResultDto.timeSpent, ); } - + // Create and save the entity const gameResult = this.gameResultsRepository.create(createGameResultDto); const savedResult = await this.gameResultsRepository.save(gameResult); - + // Emit event for other parts of the system this.eventEmitter.emit( 'game.result.created', @@ -65,30 +67,35 @@ export class GameResultsService { savedResult.achievements || [], ), ); - - this.logger.log(`Game result created for user ${savedResult.userId} with score ${savedResult.score}`); - + + this.logger.log( + `Game result created for user ${savedResult.userId} with score ${savedResult.score}`, + ); + return savedResult; } /** * Generate a leaderboard for a specific game */ - async generateLeaderboard(gameId: string, limit = 10): Promise { + async generateLeaderboard( + gameId: string, + limit = 10, + ): Promise { // Get top scores for the specified game const results = await this.gameResultsRepository.find({ where: { gameId }, order: { score: 'DESC' }, take: limit, }); - + // Transform into leaderboard entries with ranks const leaderboard = await Promise.all( results.map(async (result, index) => { // In a real app, you might want to fetch username from a user service // This is a simplified example const username = `User_${result.userId.substring(0, 6)}`; - + const entry: LeaderboardEntryDto = { userId: result.userId, username, @@ -97,18 +104,21 @@ export class GameResultsService { achievements: result.achievements || [], gameId: result.gameId, }; - + return entry; }), ); - + return leaderboard; } /** * Get user's personal best for a specific game */ - async getUserBest(userId: string, gameId: string): Promise { + async getUserBest( + userId: string, + gameId: string, + ): Promise { return this.gameResultsRepository.findOne({ where: { userId, gameId }, order: { score: 'DESC' }, @@ -118,10 +128,17 @@ export class GameResultsService { /** * Get all results for a specific user */ - async getUserResults(userId: string): Promise { + async getUserResults( + userId: string, + page: number = 1, + limit: number = 20, + ): Promise { + const offset = (page - 1) * limit; return this.gameResultsRepository.find({ where: { userId }, order: { createdAt: 'DESC' }, + skip: offset, + take: limit, }); } -} \ No newline at end of file +} diff --git a/backend/src/questions/dto/get-question-base-dto.dto.ts b/backend/src/questions/dto/get-question-base-dto.dto.ts new file mode 100644 index 00000000..f45fca7e --- /dev/null +++ b/backend/src/questions/dto/get-question-base-dto.dto.ts @@ -0,0 +1,20 @@ +import { IsDate, IsOptional } from 'class-validator'; + +import { IntersectionType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { PaginationQueryDto } from 'src/common/pagination/pagination-query-dto.dto'; + +class GetQuestionBaseDto { + @IsDate() + @IsOptional() + startDate?: Date; + + @IsDate() + @IsOptional() + endDate?: Date; +} + +export class GetQuestionDto extends IntersectionType( + GetQuestionBaseDto, + PaginationQueryDto, +) {} diff --git a/backend/src/questions/questions.controller.ts b/backend/src/questions/questions.controller.ts index 081be90e..ed21aabb 100644 --- a/backend/src/questions/questions.controller.ts +++ b/backend/src/questions/questions.controller.ts @@ -1,11 +1,29 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + DefaultValuePipe, + ParseIntPipe, +} from '@nestjs/common'; import { QuestionsService } from './questions.service'; import { CreateQuestionDto } from './dto/create-question.dto'; import { UpdateQuestionDto } from './dto/update-question.dto'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { QuestionFilters } from './dto/question-filters.dto'; +import { Question } from './entities/question.entity'; +import { Repository } from 'typeorm'; @Controller('questions') export class QuestionsController { - constructor(private readonly questionsService: QuestionsService) {} + constructor( + private readonly questionsService: QuestionsService, + private readonly questionRepository: Repository, + ) {} @Post() create(@Body() createQuestionDto: CreateQuestionDto) { @@ -13,18 +31,38 @@ export class QuestionsController { } @Get() - findAll() { - return this.questionsService.findAll(); + @ApiOperation({ summary: 'Get all Questions' }) + @ApiResponse({ + status: 200, + description: 'List of all Questions successfully retrieved', + }) + public getQuestions( + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('difficulty', new DefaultValuePipe(null), ParseIntPipe) + difficulty?: number, + @Query('songId') songId?: string, // Assuming songId remains a string + @Query('minPoints', new DefaultValuePipe(null), ParseIntPipe) + minPoints?: number, + @Query('maxPoints', new DefaultValuePipe(null), ParseIntPipe) + maxPoints?: number, + ) { + // Create the filters object ensuring the types match your QuestionFilters type. + const filters = { page, limit, difficulty, songId, minPoints, maxPoints }; + return this.questionsService.getAll(filters); } @Get(':id') findOne(@Param('id') id: string) { - return this.questionsService.findOne(+id); + return this.questionsService.findOne(id); } @Patch(':id') - update(@Param('id') id: string, @Body() updateQuestionDto: UpdateQuestionDto) { - return this.questionsService.update(+id, updateQuestionDto); + update( + @Param('id') id: string, + @Body() updateQuestionDto: UpdateQuestionDto, + ) { + return this.questionsService.update(id, updateQuestionDto); } @Delete(':id') diff --git a/backend/src/questions/questions.service.ts b/backend/src/questions/questions.service.ts index c0d1f594..1fc020d3 100644 --- a/backend/src/questions/questions.service.ts +++ b/backend/src/questions/questions.service.ts @@ -1,5 +1,9 @@ // src/questions/questions.service.ts -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Question } from './entities/question.entity'; @@ -7,6 +11,7 @@ import { CreateQuestionDto } from './dto/create-question.dto'; import { QuestionFilters } from './dto/question-filters.dto'; import { SongsService } from '../songs/songs.service'; import { DuplicateLyricException } from './exceptions/question.exception'; +import { UpdateQuestionDto } from './dto/update-question.dto'; @Injectable() export class QuestionsService { constructor( @@ -17,10 +22,10 @@ export class QuestionsService { async create(createQuestionDto: CreateQuestionDto): Promise { await this.validateQuestion(createQuestionDto); - + const song = await this.songsService.findOne(createQuestionDto.songId); const points = this.calculatePoints(createQuestionDto.difficulty); - + const question = this.questionsRepository.create({ ...createQuestionDto, song, @@ -30,51 +35,57 @@ export class QuestionsService { return this.questionsRepository.save(question); } - async findAll(filters: QuestionFilters): Promise { + async getAll( + filters: QuestionFilters & { page: number; limit: number }, + ): Promise { const query = this.questionsRepository.createQueryBuilder('question'); if (filters.difficulty) { - query.andWhere('question.difficulty = :difficulty', { - difficulty: filters.difficulty + query.andWhere('question.difficulty = :difficulty', { + difficulty: filters.difficulty, }); } if (filters.songId) { - query.andWhere('question.songId = :songId', { - songId: filters.songId - }); + query.andWhere('question.songId = :songId', { songId: filters.songId }); } if (filters.minPoints) { - query.andWhere('question.points >= :minPoints', { - minPoints: filters.minPoints + query.andWhere('question.points >= :minPoints', { + minPoints: filters.minPoints, }); } if (filters.maxPoints) { - query.andWhere('question.points <= :maxPoints', { - maxPoints: filters.maxPoints + query.andWhere('question.points <= :maxPoints', { + maxPoints: filters.maxPoints, }); } + // Apply pagination if provided + const page = filters.page || 1; + const limit = filters.limit || 20; + const offset = (page - 1) * limit; + query.skip(offset).take(limit); + return query.getMany(); } async findOne(id: string): Promise { - const question = await this.questionsRepository.findOne({ - where: { id } + const question = await this.questionsRepository.findOne({ + where: { id }, }); - + if (!question) { throw new NotFoundException(`Question with ID ${id} not found`); } - + return question; } async getRandomQuestionsByDifficulty( - difficulty: number, - count: number + difficulty: number, + count: number, ): Promise { const questions = await this.questionsRepository .createQueryBuilder('question') @@ -85,7 +96,7 @@ export class QuestionsService { if (questions.length < count) { throw new BadRequestException( - `Not enough questions available for difficulty level ${difficulty}` + `Not enough questions available for difficulty level ${difficulty}`, ); } @@ -94,21 +105,21 @@ export class QuestionsService { async updateStats(id: string, wasCorrect: boolean): Promise { const question = await this.findOne(id); - + question.timesUsed += 1; if (wasCorrect) { question.correctAnswers += 1; } - + return this.questionsRepository.save(question); } private async validateQuestion( - createQuestionDto: CreateQuestionDto + createQuestionDto: CreateQuestionDto, ): Promise { // Check for duplicate lyrics const existingQuestion = await this.questionsRepository.findOne({ - where: { lyricSnippet: createQuestionDto.lyricSnippet } + where: { lyricSnippet: createQuestionDto.lyricSnippet }, }); if (existingQuestion) { @@ -127,8 +138,10 @@ export class QuestionsService { } // Validate correct answer is within options range - if (createQuestionDto.correctAnswer < 0 || - createQuestionDto.correctAnswer >= createQuestionDto.options.length) { + if ( + createQuestionDto.correctAnswer < 0 || + createQuestionDto.correctAnswer >= createQuestionDto.options.length + ) { throw new BadRequestException('Invalid correct answer index'); } } @@ -136,17 +149,36 @@ export class QuestionsService { private calculatePoints(difficulty: number): number { // Base points calculation based on difficulty const basePoints = difficulty * 100; - + // Additional bonus for higher difficulties const difficultyBonus = Math.pow(difficulty, 2) * 10; - + return basePoints + difficultyBonus; } private handleDatabaseError(error: any): never { - if (error.code === '23505') { // Unique constraint violation + if (error.code === '23505') { + // Unique constraint violation throw new DuplicateLyricException(); } throw error; } + // Updates a question + async update( + id: string, + updateQuestionDto: UpdateQuestionDto, + ): Promise { + const question = await this.findOne(id); + // Merge the update DTO into the found question + const updatedQuestion = Object.assign(question, updateQuestionDto); + return this.questionsRepository.save(updatedQuestion); + } + + // Deletes a question + async remove(id: number): Promise { + const result = await this.questionsRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`Question with id ${id} not found`); + } + } } diff --git a/backend/src/room/dto/get-room-base-dto.dto.ts b/backend/src/room/dto/get-room-base-dto.dto.ts new file mode 100644 index 00000000..3117043d --- /dev/null +++ b/backend/src/room/dto/get-room-base-dto.dto.ts @@ -0,0 +1,20 @@ +import { IsDate, IsOptional } from 'class-validator'; + +import { IntersectionType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { PaginationQueryDto } from 'src/common/pagination/pagination-query-dto.dto'; + +class GetRoomBaseDto { + @IsDate() + @IsOptional() + startDate?: Date; + + @IsDate() + @IsOptional() + endDate?: Date; +} + +export class GetRoomDto extends IntersectionType( + GetRoomBaseDto, + PaginationQueryDto, +) {} diff --git a/backend/src/room/room.controller.ts b/backend/src/room/room.controller.ts index c82532fe..6654053b 100644 --- a/backend/src/room/room.controller.ts +++ b/backend/src/room/room.controller.ts @@ -8,6 +8,8 @@ import { Param, Delete, Query, + DefaultValuePipe, + ParseIntPipe, } from '@nestjs/common'; import { ApiTags, @@ -46,8 +48,11 @@ export class RoomController { status: 200, description: 'List of all room successfully retrieved', }) - findAll() { - return this.roomService.findAll(); + public getRooms( + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + ) { + return this.roomService.getAll(limit, page); } @Get(':id') diff --git a/backend/src/room/room.service.ts b/backend/src/room/room.service.ts index 96c9549a..da56d08f 100644 --- a/backend/src/room/room.service.ts +++ b/backend/src/room/room.service.ts @@ -24,8 +24,13 @@ export class RoomService { return this.roomRepository.save(room); } - findAll() { - return this.roomRepository.find(); + public async getAll(limit: number, page: number): Promise { + const skip = (page - 1) * limit; + + return this.roomRepository.find({ + skip, + take: limit, + }); } async findOne(id: string) { diff --git a/backend/src/songs/dto/get-songs-base-dto.dto.ts b/backend/src/songs/dto/get-songs-base-dto.dto.ts new file mode 100644 index 00000000..5c90cce5 --- /dev/null +++ b/backend/src/songs/dto/get-songs-base-dto.dto.ts @@ -0,0 +1,20 @@ +import { IsDate, IsOptional } from 'class-validator'; + +import { IntersectionType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { PaginationQueryDto } from 'src/common/pagination/pagination-query-dto.dto'; + +class GetSongsBaseDto { + @IsDate() + @IsOptional() + startDate?: Date; + + @IsDate() + @IsOptional() + endDate?: Date; +} + +export class GetSongsDto extends IntersectionType( + GetSongsBaseDto, + PaginationQueryDto, +) {} diff --git a/backend/src/songs/songs.controller.ts b/backend/src/songs/songs.controller.ts index c0703877..75e889bf 100644 --- a/backend/src/songs/songs.controller.ts +++ b/backend/src/songs/songs.controller.ts @@ -1,5 +1,16 @@ // src/songs/songs.controller.ts -import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + DefaultValuePipe, + ParseIntPipe, +} from '@nestjs/common'; import { SongsService } from './songs.service'; import { CreateSongDto } from './dto/create-song.dto'; import { UpdateSongDto } from './dto/update-song.dto'; @@ -23,19 +34,28 @@ export class SongsController { } @Get() - @ApiOperation({ summary: 'Get songs with filtering, sorting, and searching' }) - async getSongs( - @Query('difficulty') difficultyId?: string, - @Query('sortBy') sortBy?: string, - @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', - @Query('q') searchQuery?: string, // Added search parameter -) { - return this.songsService.searchSongs( - searchQuery, - { difficultyId }, - { field: sortBy, order: sortOrder }, - ); -} + @ApiOperation({ + summary: 'Get songs with filtering, sorting, searching and pagination', + }) + @ApiResponse({ + status: 200, + description: 'List of songs successfully retrieved', + }) + async getAllSongs( + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('difficulty') difficultyId?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + @Query('q') searchQuery?: string, + ) { + return this.songsService.getAllSongs( + searchQuery, + { difficultyId }, + { field: sortBy, order: sortOrder }, + { page, limit }, + ); + } @Get('search') @ApiOperation({ summary: 'Search songs' }) @@ -78,4 +98,4 @@ export class SongsController { incrementPlayCount(@Param('id') id: string) { return this.songsService.updatePlayCount(id); } -} \ No newline at end of file +} diff --git a/backend/src/songs/songs.service.ts b/backend/src/songs/songs.service.ts index a0962f6c..94d6b537 100644 --- a/backend/src/songs/songs.service.ts +++ b/backend/src/songs/songs.service.ts @@ -1,17 +1,25 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + DefaultValuePipe, + Injectable, + NotFoundException, + ParseIntPipe, + Query, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Like, Repository } from 'typeorm'; import { CreateSongDto } from './dto/create-song.dto'; import { UpdateSongDto } from './dto/update-song.dto'; import { Song } from './entities/song.entity'; import { RedisService } from 'src/redis/redis.service'; import { Difficulty } from 'src/difficulty/entities/difficulty.entity'; +import { SongService } from 'src/song/providers/song.service'; @Injectable() export class SongsService { constructor( @InjectRepository(Song) private songsRepository: Repository, + private readonly songService: SongService, // injecting the redis sercvice private redisService: RedisService, private readonly difficultyRepository: Repository, @@ -24,19 +32,59 @@ export class SongsService { return savedSong; } - async findAll() { - const cacheKey = 'songs:all'; - + const songs = await this.songsRepository.find(); + return songs; + } + async getAllSongs( + searchQuery?: string, + filters?: { difficultyId?: string }, + sortOptions?: { field?: string; order?: 'ASC' | 'DESC' }, + pagination?: { page: number; limit: number }, + ): Promise { + // Destructure pagination parameters with default values + const { page = 1, limit = 20 } = pagination || {}; + + // Build a unique cache key using all parameters + const cacheKey = `songs:all:difficulty:${filters?.difficultyId || 'all'}:q:${searchQuery || 'all'}:sort:${sortOptions?.field || 'none'}:${sortOptions?.order || 'none'}:limit:${limit}:page:${page}`; + + // Check for cached results const cachedSongs = await this.redisService.get(cacheKey); if (cachedSongs) { return JSON.parse(cachedSongs); } - - const songs = await this.songsRepository.find(); - + + // Calculate offset for pagination + const offset = (page - 1) * limit; + + // Build query options for TypeORM + const queryOptions: any = { + skip: offset, + take: limit, + where: {}, + }; + + // Apply filtering if provided + if (filters?.difficultyId) { + queryOptions.where.difficultyId = filters.difficultyId; + } + + // Apply search (example: searching by title) + if (searchQuery) { + queryOptions.where.title = Like(`%${searchQuery}%`); + } + + // Apply sorting if provided + if (sortOptions?.field) { + queryOptions.order = { [sortOptions.field]: sortOptions.order || 'ASC' }; + } + + // Execute the query to get songs + const songs = await this.songsRepository.find(queryOptions); + + // Cache the results for 1 hour (3600 seconds) await this.redisService.set(cacheKey, JSON.stringify(songs), 3600); - + return songs; } @@ -98,36 +146,41 @@ export class SongsService { async searchSongs( searchQuery: string, filters?: { difficultyId: string }, - sort?: { field: string, order: 'ASC' | 'DESC' } + sort?: { field: string; order: 'ASC' | 'DESC' }, ) { const cacheKey = `songs:search:${searchQuery}`; const cachedResults = await this.redisService.get(cacheKey); if (cachedResults) { - return JSON.parse(cachedResults) + return JSON.parse(cachedResults); } - const query = this.songsRepository.createQueryBuilder('songs') + const query = this.songsRepository.createQueryBuilder('songs'); if (filters?.difficultyId) { - query.andWhere('song.difficultyId = :difficultyId', { difficultyId: filters.difficultyId }) - .getMany(); + query + .andWhere('song.difficultyId = :difficultyId', { + difficultyId: filters.difficultyId, + }) + .getMany(); } - + // Integrated search if (searchQuery) { - query.andWhere( - '(song.title ILIKE :searchQuery OR song.artist ILIKE :searchQuery)', - { searchQuery: `%${searchQuery}%` } - ).getMany(); + query + .andWhere( + '(song.title ILIKE :searchQuery OR song.artist ILIKE :searchQuery)', + { searchQuery: `%${searchQuery}%` }, + ) + .getMany(); } - + if (sort?.field) { query.orderBy(`song.${sort.field}`, sort.order || 'ASC').getMany(); } await this.redisService.set(cacheKey, JSON.stringify(query), 1800); - return query + return query; } async updatePlayCount(id: string) { @@ -135,21 +188,27 @@ export class SongsService { song.playCount += 1; const updatedSong = await this.songsRepository.save(song); - await this.redisService.set(`song:${id}`, JSON.stringify(updatedSong), 3600); + await this.redisService.set( + `song:${id}`, + JSON.stringify(updatedSong), + 3600, + ); return updatedSong; } async findByDifficulty(level: number) { - const difficulty = await this.difficultyRepository.findOne({ where: { value: level } }); + const difficulty = await this.difficultyRepository.findOne({ + where: { value: level }, + }); if (!difficulty) { - throw new NotFoundException(`Difficulty level ${level} not found`) + throw new NotFoundException(`Difficulty level ${level} not found`); } return this.songsRepository.find({ - where: { difficultyId: difficulty.id } - }) + where: { difficultyId: difficulty.id }, + }); } async getRandomSong() { diff --git a/backend/src/user/dtos/get-users-base-dto.dto.ts b/backend/src/user/dtos/get-users-base-dto.dto.ts new file mode 100644 index 00000000..8ad93abf --- /dev/null +++ b/backend/src/user/dtos/get-users-base-dto.dto.ts @@ -0,0 +1,20 @@ +import { IsDate, IsOptional } from 'class-validator'; + +import { IntersectionType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { PaginationQueryDto } from 'src/common/pagination/pagination-query-dto.dto'; + +class GetUsersBaseDto { + @IsDate() + @IsOptional() + startDate?: Date; + + @IsDate() + @IsOptional() + endDate?: Date; +} + +export class GetUsersDto extends IntersectionType( + GetUsersBaseDto, + PaginationQueryDto, +) {} diff --git a/backend/src/user/providers/user.service.ts b/backend/src/user/providers/user.service.ts index 384e5604..6b4d3349 100644 --- a/backend/src/user/providers/user.service.ts +++ b/backend/src/user/providers/user.service.ts @@ -9,10 +9,7 @@ import { UserDTO } from '../dtos/create-user.dto'; import { CustomLoggerService } from '../../logger/custom-logger.service'; import { v4 as uuidv4 } from 'uuid'; - -const requestId = uuidv4(); - - +const requestId = uuidv4(); // Service responsible for handling user operations. @Injectable() @@ -38,6 +35,15 @@ export class UserService { this.logger.setContext('UserService'); // Set logging context } + public async getAll(limit: number, page: number): Promise { + const skip = (page - 1) * limit; + + return this.userRepository.find({ + skip, + take: limit, + }); + } + async createUser(userData: any) { this.logger.log( `Creating new user: ${JSON.stringify({ @@ -45,7 +51,6 @@ export class UserService { timestamp: new Date(), requestId: uuidv4(), })}`, - ); try { diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index e7c90a9b..9712b065 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -8,6 +8,9 @@ import { Delete, UseGuards, UseInterceptors, + Query, + DefaultValuePipe, + ParseIntPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; import { UserService } from './providers/user.service'; @@ -25,196 +28,203 @@ import { LoggingInterceptor } from 'src/interceptors/logging.interceptor'; @ApiTags('user') @Controller('user') @UseGuards(RolesGuard) // guards to restrict specific User access to some routes - @UseInterceptors(LoggingInterceptor) export class UserController { - constructor( - private readonly userService: UserService //dependency injection of userService - ) {} - - // Sign up a new user - @Post('signup') - @ApiOperation({ - summary: 'Sign up a new user', - description: 'Create a new user account', - }) - @ApiBody({ type: UserDTO }) - @ApiResponse({ - status: 201, - description: 'User successfully created', - }) - @ApiResponse({ - status: 400, - description: 'Invalid input', - }) - signUp(@Body() userDto: UserDTO) { - return this.userService.signUp(userDto); - } - - // Sign In a user - @Post('signin') - @ApiOperation({ - summary: 'Sign in a user', - description: 'Authenticate user credentials', - }) - // @ApiBody({ type: SignInDTO }) - @ApiResponse({ - status: 200, - description: 'User successfully signed in', - }) - @ApiResponse({ - status: 401, - description: 'Invalid credentials', - }) - signIn() { - return this.userService.signIn(); - } - - // Retrieve user refresh access token - @UseGuards(AccessTokenGuard) - @Post('refresh-token') - @ApiOperation({ - summary: 'Refresh user access token', - description: 'Generate a new access token using refresh token', - }) - //@ApiBody({ type: RefreshTokenDTO }) - @ApiResponse({ - status: 200, - description: 'Access token successfully refreshed', - }) - @ApiResponse({ - status: 401, - description: 'Invalid refresh token', - }) - refreshToken() { - return this.userService.refreshToken(); - } - - // GET - @Get('admin') - @Roles(UserRole.ADMIN) - getAdminData() { - // should return the logic of admin from userService - return 'this returns admin roles '; - } - - @Get('admin/:id') - @Roles(UserRole.ADMIN) - getAdminById(@Param('id') id: number) { - // should return the logic of admin from userService - return 'this returns single admin by his ID '; - } - - @Get('admins') - @Roles(UserRole.ADMIN) - getAllAdmins() { - // should return the logic of admin from userService - return 'this returns all admins '; - } - - @Put('admin/:id') - @Roles(UserRole.ADMIN) - updateAdminById(@Param('id') id: number, @Body() userDto: UserDTO) { - // should return the logic of admin from userService - return 'this updates an admin '; - } - - @Delete('admin/:id') - @Roles(UserRole.ADMIN) - deleteAdminById(@Param('id') id: number) { - // should return the logic of admin from userService - return 'this deletes an admin '; - } - - @Get('player') - @Roles(UserRole.PLAYER) - getPlayerData() { - // should return the logic of player from userService - return 'this returns player specific roles '; - } - - @Get('player/:id') - @Roles(UserRole.PLAYER) - getPlayerById(@Param('id') id: number) { - // should return the logic of player from userService - return 'this returns a single player '; - } - - @Get('players') - @Roles(UserRole.PLAYER) - getAllPlayers() { + constructor( + private readonly userService: UserService, //dependency injection of userService + ) {} + + //get users + @Get() + public getUsers( + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + ) { + return this.userService.getAll(limit, page); + } + + // Sign up a new user + @Post('signup') + @ApiOperation({ + summary: 'Sign up a new user', + description: 'Create a new user account', + }) + @ApiBody({ type: UserDTO }) + @ApiResponse({ + status: 201, + description: 'User successfully created', + }) + @ApiResponse({ + status: 400, + description: 'Invalid input', + }) + signUp(@Body() userDto: UserDTO) { + return this.userService.signUp(userDto); + } + + // Sign In a user + @Post('signin') + @ApiOperation({ + summary: 'Sign in a user', + description: 'Authenticate user credentials', + }) + // @ApiBody({ type: SignInDTO }) + @ApiResponse({ + status: 200, + description: 'User successfully signed in', + }) + @ApiResponse({ + status: 401, + description: 'Invalid credentials', + }) + signIn() { + return this.userService.signIn(); + } + + // Retrieve user refresh access token + @UseGuards(AccessTokenGuard) + @Post('refresh-token') + @ApiOperation({ + summary: 'Refresh user access token', + description: 'Generate a new access token using refresh token', + }) + //@ApiBody({ type: RefreshTokenDTO }) + @ApiResponse({ + status: 200, + description: 'Access token successfully refreshed', + }) + @ApiResponse({ + status: 401, + description: 'Invalid refresh token', + }) + refreshToken() { + return this.userService.refreshToken(); + } + + // GET + @Get('admin') + @Roles(UserRole.ADMIN) + getAdminData() { + // should return the logic of admin from userService + return 'this returns admin roles '; + } + + @Get('admin/:id') + @Roles(UserRole.ADMIN) + getAdminById(@Param('id') id: number) { + // should return the logic of admin from userService + return 'this returns single admin by his ID '; + } + + @Get('admins') + @Roles(UserRole.ADMIN) + getAllAdmins() { + // should return the logic of admin from userService + return 'this returns all admins '; + } + + @Put('admin/:id') + @Roles(UserRole.ADMIN) + updateAdminById(@Param('id') id: number, @Body() userDto: UserDTO) { + // should return the logic of admin from userService + return 'this updates an admin '; + } + + @Delete('admin/:id') + @Roles(UserRole.ADMIN) + deleteAdminById(@Param('id') id: number) { + // should return the logic of admin from userService + return 'this deletes an admin '; + } + + @Get('player') + @Roles(UserRole.PLAYER) + getPlayerData() { + // should return the logic of player from userService + return 'this returns player specific roles '; + } + + @Get('player/:id') + @Roles(UserRole.PLAYER) + getPlayerById(@Param('id') id: number) { + // should return the logic of player from userService + return 'this returns a single player '; + } + + @Get('players') + @Roles(UserRole.PLAYER) + getAllPlayers() { // should return the logic of player from userService return 'this returns all players '; - } + } - @Put('player/:id') - @Roles(UserRole.PLAYER) - updatePlayerById(@Param('id') id: number, @Body() userDto: UserDTO) { + @Put('player/:id') + @Roles(UserRole.PLAYER) + updatePlayerById(@Param('id') id: number, @Body() userDto: UserDTO) { // should return the logic of player from userService return 'this edits a single player '; - } - - @Delete('player/:id') - @Roles(UserRole.PLAYER) - deletePlayerById(@Param('id') id: number) { - // should return the logic of player from userService - return 'this deletes a player '; - } - - //USERS ROUTES - @Get('user') - @Roles(UserRole.USER) - getViewerData() { - // should return the logic of user from userService - return 'this returns all viewers ' - } - - @Get('user/:id') - @Roles(UserRole.USER) - getUserById(@Param('id') id: number) { - // should return the logic of user from userService - return 'this returns a single user ' - } - - @Get('users') - @Roles(UserRole.USER) - getAllUsers() { - // should return the logic of user from userService - return 'this returns all users ' - } - - @Put('user/:id') - @Roles(UserRole.USER) - updateUserById(@Param('id') id: number, @Body() userDto: UserDTO) { + } + + @Delete('player/:id') + @Roles(UserRole.PLAYER) + deletePlayerById(@Param('id') id: number) { + // should return the logic of player from userService + return 'this deletes a player '; + } + + //USERS ROUTES + @Get('user') + @Roles(UserRole.USER) + getViewerData() { + // should return the logic of user from userService + return 'this returns all viewers '; + } + + @Get('user/:id') + @Roles(UserRole.USER) + getUserById(@Param('id') id: number) { + // should return the logic of user from userService + return 'this returns a single user '; + } + + @Get('users') + @Roles(UserRole.USER) + getAllUsers() { + // should return the logic of user from userService + return 'this returns all users '; + } + + @Put('user/:id') + @Roles(UserRole.USER) + updateUserById(@Param('id') id: number, @Body() userDto: UserDTO) { // should return the logic of user from userService - return 'this updates a users ' - } + return 'this updates a users '; + } - @Delete('user/:id') - @Roles(UserRole.USER) - deleteUserById(@Param('id') id: number) { + @Delete('user/:id') + @Roles(UserRole.USER) + deleteUserById(@Param('id') id: number) { // should return the logic of user from userService - return 'this deletes a single users ' - } - - - // Update user profile - @UseGuards(AccessTokenGuard) - @Put('profile') - @ApiOperation({ - summary: 'Update user profile', - description: 'Modify user profile information', - }) - // @ApiBody({ type: UpdateProfileDTO }) - @ApiResponse({ - status: 200, - description: 'Profile successfully updated', - }) - @ApiResponse({ - status: 400, - description: 'Invalid profile data', - }) - updateProfile() { - return this.userService.updateProfile(); - } + return 'this deletes a single users '; + } + + // Update user profile + @UseGuards(AccessTokenGuard) + @Put('profile') + @ApiOperation({ + summary: 'Update user profile', + description: 'Modify user profile information', + }) + // @ApiBody({ type: UpdateProfileDTO }) + @ApiResponse({ + status: 200, + description: 'Profile successfully updated', + }) + @ApiResponse({ + status: 400, + description: 'Invalid profile data', + }) + updateProfile() { + return this.userService.updateProfile(); + } }