From 3694f3e6ca564211db589b64f01aa9473520e372 Mon Sep 17 00:00:00 2001 From: Daehyun Kim <18080546+vimkim@users.noreply.github.com> Date: Sun, 11 Feb 2024 00:43:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(ranking-api):=20ranking=20api=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=99=84=EC=84=B1=20(#264)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ranking-api): ranking api 완성 * remove unnecessary comments and add api tags * feat(app): stack trace limit Infinity * lint(eslint): unused var allowed with _ * merge main * recover baseUrl (jest fixed) * isNil 삭제: 아직 필요하지 않음 * add login dto * refactor(authController): clean up * feat(utils): add isNil * setting(package.json): passWithNoTests * refactor(room-user): reorder imports * RankingResponseDto id 삭제 * validateMockUser는 mock유저만 가능하도록 수정 * room service import ordering * add submission ranking endpoint * any 타입을 강타입으로 * implement two ranking api * remove unused endpoint room/:code/ranking * fix(submission.service): runtime query error addSelect -> select * fix(jest): add moduleNameMapper to make it work * refactor(submissionStatDto): do not use it anymore * refactor: 안 쓰는 datasource 삭제 --- server/package.json | 6 +- server/src/app.controller.ts | 7 +- server/src/app.module.ts | 12 ++- server/src/auth/auth.controller.ts | 20 ++-- server/src/auth/auth.service.ts | 6 ++ server/src/auth/login.dto.ts | 18 ++++ server/src/common/utils.ts | 2 + server/src/main.ts | 16 ++-- .../src/room-user/dto/ranking-response.dto.ts | 5 + server/src/room-user/room-user.service.ts | 19 +++- server/src/room/room.controller.ts | 21 ++++- server/src/room/room.module.ts | 4 +- server/src/room/room.service.ts | 11 ++- .../src/submission/submission.controller.ts | 13 ++- server/src/submission/submission.module.ts | 5 +- .../src/submission/submission.repository.ts | 0 server/src/submission/submission.service.ts | 91 ++++++++++++++++--- server/test/jest-e2e.json | 4 + server/tsconfig.json | 3 +- 19 files changed, 214 insertions(+), 49 deletions(-) create mode 100644 server/src/auth/login.dto.ts create mode 100644 server/src/common/utils.ts create mode 100644 server/src/room-user/dto/ranking-response.dto.ts create mode 100644 server/src/submission/submission.repository.ts diff --git a/server/package.json b/server/package.json index 1d388f8..a03162a 100644 --- a/server/package.json +++ b/server/package.json @@ -14,7 +14,7 @@ "prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint:dryrun": "eslint \"{src,apps,libs,test}/**/*.ts\"", - "test": "jest", + "test": "jest --passWithNoTests", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", @@ -86,6 +86,10 @@ "ts" ], "rootDir": "src", + "moduleNameMapper": { + "src/(.*)$": "/$1", + "test/(.*)$": "/$1" + }, "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" diff --git a/server/src/app.controller.ts b/server/src/app.controller.ts index 7488ae7..74dd0f4 100644 --- a/server/src/app.controller.ts +++ b/server/src/app.controller.ts @@ -6,13 +6,15 @@ import { Req, UseGuards, } from '@nestjs/common'; -import { AppService } from './app.service'; +import { ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; +import { AppService } from './app.service'; import { SessionAuthGuard } from './auth/auth.guard'; import User from './entities/user.entity'; import { RoomService } from './room/room.service'; -import { isUserSession, UserSession } from './types/user-session'; +import { UserSession, isUserSession } from './types/user-session'; +@ApiTags('app') @UseGuards(SessionAuthGuard) @Controller() export class AppController { @@ -24,7 +26,6 @@ export class AppController { @Get() getHello(): string { - // this.logger.log('get hello world'); return this.appService.getHello(); } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index dec6c80..a835603 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -8,11 +8,12 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { ProblemModule } from './problem/problem.module'; +import { RoomUserModule } from './room-user/room-user.module'; import { RoomModule } from './room/room.module'; -import { SocketModule } from './socket/socket.module'; -import { UserModule } from './user/user.module'; import { ShortLoggerService } from './short-logger/short-logger.service'; +import { SocketModule } from './socket/socket.module'; import { SubmissionModule } from './submission/submission.module'; +import { UserModule } from './user/user.module'; @Module({ imports: [ @@ -30,7 +31,7 @@ import { SubmissionModule } from './submission/submission.module'; password: process.env.DB_PASSWORD, database: process.env.DB_NAME, entities: [__dirname + '/**/*.entity.*'], - logging: false, + logging: true, synchronize: true, // production시 false로 변경 namingStrategy: new SnakeNamingStrategy(), }), @@ -40,8 +41,11 @@ import { SubmissionModule } from './submission/submission.module'; RoomModule, ProblemModule, SubmissionModule, + RoomUserModule, ], controllers: [AppController], providers: [AppService, Logger, ShortLoggerService], }) -export class AppModule {} +export class AppModule { + constructor() {} +} diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index b1f2e7d..a133dcf 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { + Body, Controller, Get, Logger, @@ -7,9 +8,12 @@ import { Res, UseGuards, } from '@nestjs/common'; -import { GithubAuthGuard, MockAuthGuard, SessionAuthGuard } from './auth.guard'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; +import { GithubAuthGuard, MockAuthGuard, SessionAuthGuard } from './auth.guard'; +import { LoginDto } from './login.dto'; +@ApiTags('auth') @Controller('auth') export class AuthController { private readonly logger = new Logger(AuthController.name); @@ -18,22 +22,22 @@ export class AuthController { @Get('github') @UseGuards(GithubAuthGuard) - async login() { - this.logger.debug('login...'); - } + async login() {} @Get('github/callback') @UseGuards(GithubAuthGuard) async authCallback(@Req() req: Request, @Res() res: Response) { - this.logger.debug('authCallback...'); res.redirect(`${process.env.CLIENT_URL}/home`); } @Post('mock') @UseGuards(MockAuthGuard) - async mockLogin() { - this.logger.debug('MockAuthGuard passed!'); - // res.redirect(`${process.env.CLIENT_URL}/home`); + @ApiOperation({ + summary: 'Mock Login', + description: 'Supports Mock Login for development purpose.', + }) + async mockLogin(@Body() loginDto: LoginDto) { + this.logger.debug('MockAuthGuard passed! loginDto: ', loginDto); return 'mock user login successful!'; } diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index bdf7b9b..bd6edd1 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -14,6 +14,12 @@ export class AuthService { provider: username, providerId: password, }; + + if (!username.startsWith('mock')) { + this.logger.error('username must start with "mock"'); + throw new BadRequestException('잘못된 로그인 요청입니다!'); + } + const user = await this.userService.findUserByProviderInfo(mockProviderInfo); diff --git a/server/src/auth/login.dto.ts b/server/src/auth/login.dto.ts new file mode 100644 index 0000000..f0df1aa --- /dev/null +++ b/server/src/auth/login.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class LoginDto { + @ApiProperty({ + example: 'mockuser', + required: true, + }) + @IsString() + username!: string; + + @ApiProperty({ + example: 'mockuser', + required: true, + }) + @IsString() + password!: string; +} diff --git a/server/src/common/utils.ts b/server/src/common/utils.ts new file mode 100644 index 0000000..6c920ac --- /dev/null +++ b/server/src/common/utils.ts @@ -0,0 +1,2 @@ +export const isNil = (value: any): value is null | undefined => + value === null || value === undefined; diff --git a/server/src/main.ts b/server/src/main.ts index a5cbb42..255068a 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,16 +1,18 @@ +import { ValidationPipe } from '@nestjs/common'; import { HttpAdapterHost, NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { AppModule } from './app.module'; -import * as session from 'express-session'; -import * as passport from 'passport'; +import RedisStore from 'connect-redis'; import * as cookieParser from 'cookie-parser'; -import { ValidationPipe } from '@nestjs/common'; +import * as session from 'express-session'; +import Redis from 'ioredis'; import * as morgan from 'morgan'; -import { ShortLoggerService } from './short-logger/short-logger.service'; +import * as passport from 'passport'; +import { AppModule } from './app.module'; import { ExceptionsFilter } from './exceptions/exceptions.filter'; +import { ShortLoggerService } from './short-logger/short-logger.service'; import { SocketIOAdapter } from './socket/socket.adapter'; -import Redis from 'ioredis'; -import RedisStore from 'connect-redis'; + +Error.stackTraceLimit = Infinity; async function bootstrap() { const app = await NestFactory.create(AppModule, { diff --git a/server/src/room-user/dto/ranking-response.dto.ts b/server/src/room-user/dto/ranking-response.dto.ts new file mode 100644 index 0000000..28138dd --- /dev/null +++ b/server/src/room-user/dto/ranking-response.dto.ts @@ -0,0 +1,5 @@ +export interface RankingResponseDto { + username: string; + numberOfProblemsSolved: number; + mostRecentCorrectSubmissionTime: string; +} diff --git a/server/src/room-user/room-user.service.ts b/server/src/room-user/room-user.service.ts index 8f0fe6e..c2a4a56 100644 --- a/server/src/room-user/room-user.service.ts +++ b/server/src/room-user/room-user.service.ts @@ -1,12 +1,14 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import RoomUser from './room-user.entity'; +import { Repository } from 'typeorm'; import User from '../entities/user.entity'; import { RoomUserInput } from '../types/room-user-input'; -import { Repository } from 'typeorm'; +import RoomUser from './room-user.entity'; @Injectable() export class RoomUserService { + private readonly logger = new Logger(RoomUserService.name); + constructor( @InjectRepository(RoomUser) private readonly roomUserRepository: Repository, @@ -31,4 +33,15 @@ export class RoomUserService { where: { room: { code: roomCode } }, }); } + + async findUsersByRoomCode(code: string) { + const qb = this.roomUserRepository + .createQueryBuilder('roomUser') + .innerJoin('roomUser.room', 'room', 'room.code = :code', { code }) + .innerJoinAndSelect('roomUser.user', 'user'); + + const roomUsers = await qb.getMany(); + + return roomUsers.map((roomUser) => roomUser.user); + } } diff --git a/server/src/room/room.controller.ts b/server/src/room/room.controller.ts index d9e9724..62fc72b 100644 --- a/server/src/room/room.controller.ts +++ b/server/src/room/room.controller.ts @@ -1,18 +1,21 @@ import { Body, Controller, + Get, HttpCode, HttpStatus, Logger, + Param, Post, Req, UseGuards, } from '@nestjs/common'; -import { RoomService } from './room.service'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; -import User from '../entities/user.entity'; import { SessionAuthGuard } from '../auth/auth.guard'; +import User from '../entities/user.entity'; +import { RoomUserService } from '../room-user/room-user.service'; +import { RoomService } from './room.service'; @Controller('room') @ApiTags('room') @@ -20,7 +23,10 @@ import { SessionAuthGuard } from '../auth/auth.guard'; export class RoomController { private readonly logger = new Logger(RoomController.name); - constructor(private readonly roomService: RoomService) {} + constructor( + private readonly roomService: RoomService, + private readonly roomUserService: RoomUserService, + ) {} @ApiResponse({ status: 400, @@ -54,4 +60,13 @@ export class RoomController { this.logger.debug(`user ${user.username} exiting room...`); return await this.roomService.exitRoom(user); } + + @ApiOperation({ + summary: '방에 참가한 유저들 조회', + }) + @Get('/:code/users') + @HttpCode(HttpStatus.OK) + async getRoomUsers(@Param('code') code: string) { + return await this.roomUserService.findUsersByRoomCode(code); + } } diff --git a/server/src/room/room.module.ts b/server/src/room/room.module.ts index 4627d8d..5e8df93 100644 --- a/server/src/room/room.module.ts +++ b/server/src/room/room.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import Room from '../entities/room.entity'; import { RoomUserModule } from '../room-user/room-user.module'; @@ -6,6 +6,7 @@ import { UserModule } from '../user/user.module'; import { RoomController } from './room.controller'; import { RoomService } from './room.service'; import { SocketModule } from '../socket/socket.module'; +import { SubmissionModule } from '../submission/submission.module'; @Module({ imports: [ @@ -13,6 +14,7 @@ import { SocketModule } from '../socket/socket.module'; RoomUserModule, TypeOrmModule.forFeature([Room]), SocketModule, + forwardRef(() => SubmissionModule), ], controllers: [RoomController], providers: [RoomService], diff --git a/server/src/room/room.service.ts b/server/src/room/room.service.ts index 8658e9d..82c3c94 100644 --- a/server/src/room/room.service.ts +++ b/server/src/room/room.service.ts @@ -1,18 +1,21 @@ import { BadRequestException, + Inject, Injectable, InternalServerErrorException, Logger, + forwardRef, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import * as crypto from 'crypto'; +import { SubmissionService } from 'src/submission/submission.service'; +import { Repository } from 'typeorm'; import Room from '../entities/room.entity'; import User from '../entities/user.entity'; -import { RoomUserService } from '../room-user/room-user.service'; -import { UserService } from '../user/user.service'; -import { Repository } from 'typeorm'; import RoomUser from '../room-user/room-user.entity'; +import { RoomUserService } from '../room-user/room-user.service'; import { SocketService } from '../socket/socket.service'; +import { UserService } from '../user/user.service'; @Injectable() export class RoomService { @@ -26,6 +29,8 @@ export class RoomService { @InjectRepository(Room) private readonly roomRepository: Repository, private readonly socketService: SocketService, + @Inject(forwardRef(() => SubmissionService)) + private readonly submissionService: SubmissionService, ) {} /** diff --git a/server/src/submission/submission.controller.ts b/server/src/submission/submission.controller.ts index 63731d6..3efab87 100644 --- a/server/src/submission/submission.controller.ts +++ b/server/src/submission/submission.controller.ts @@ -1,11 +1,14 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Logger, Post, Query } from '@nestjs/common'; import { ApiOperation } from '@nestjs/swagger'; +import { RankingResponseDto } from 'src/room-user/dto/ranking-response.dto'; import { RoomSubmissionDto } from './dto/roomSubmission.dto'; import { SubmissionDto } from './dto/submission.dto'; import { SubmissionService } from './submission.service'; @Controller('submission') export class SubmissionController { + private readonly logger = new Logger(SubmissionController.name); + constructor(private readonly submissionService: SubmissionService) {} @ApiOperation({ @@ -25,4 +28,12 @@ export class SubmissionController { async getRoomSubmission(@Query() roomSubmissionDto: RoomSubmissionDto) { return await this.submissionService.getRoomSubmission(roomSubmissionDto); } + @Get('ranking') + async getRanking( + @Query() roomSubmissionDto: RoomSubmissionDto, + ): Promise { + return this.submissionService.getUsersRankingByRoomCode( + roomSubmissionDto.roomCode, + ); + } } diff --git a/server/src/submission/submission.module.ts b/server/src/submission/submission.module.ts index 2c489a2..abaa57b 100644 --- a/server/src/submission/submission.module.ts +++ b/server/src/submission/submission.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import Submission from '../entities/submission.entity'; import { ProblemModule } from '../problem/problem.module'; @@ -13,12 +13,13 @@ import { RoomModule } from '../room/room.module'; imports: [ UserModule, ProblemModule, - RoomModule, + forwardRef(() => RoomModule), RoomUserModule, TypeOrmModule.forFeature([Submission]), SocketModule, ], controllers: [SubmissionController], providers: [SubmissionService], + exports: [SubmissionService], }) export class SubmissionModule {} diff --git a/server/src/submission/submission.repository.ts b/server/src/submission/submission.repository.ts new file mode 100644 index 0000000..e69de29 diff --git a/server/src/submission/submission.service.ts b/server/src/submission/submission.service.ts index e712b0f..32da620 100644 --- a/server/src/submission/submission.service.ts +++ b/server/src/submission/submission.service.ts @@ -1,16 +1,23 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import * as cheerio from 'cheerio'; +import Room from 'src/entities/room.entity'; +import { RankingResponseDto } from 'src/room-user/dto/ranking-response.dto'; +import { EntityManager, Repository } from 'typeorm'; import { BojResultsToStatus, Status } from '../const/boj-results'; import Submission from '../entities/submission.entity'; import { ProblemService } from '../problem/problem.service'; import { RoomService } from '../room/room.service'; -import { RoomUserService } from '../room-user/room-user.service'; +import { SocketService } from '../socket/socket.service'; import { BojSubmissionInfo } from '../types/submission'; import { UserService } from '../user/user.service'; -import { Repository } from 'typeorm'; import { SubmissionDto } from './dto/submission.dto'; -import { SocketService } from '../socket/socket.service'; @Injectable() export class SubmissionService { @@ -21,9 +28,10 @@ export class SubmissionService { private readonly submissionRepository: Repository, private readonly userService: UserService, private readonly problemService: ProblemService, + @Inject(forwardRef(() => RoomService)) private readonly roomService: RoomService, - private readonly roomUserService: RoomUserService, private readonly socketService: SocketService, + private readonly entityManager: EntityManager, ) {} async submitCode(submissionDto: SubmissionDto) { @@ -120,10 +128,10 @@ export class SubmissionService { const allSubmissions = $('tbody > tr'); allSubmissions.each((index, element) => { - const { tmpBojSolutionId, tmpStatus } = this.getEachSubmissionInfo( - $, - element, - ); + const { tmpBojSolutionId, tmpStatus: unknownStatus } = + this.getEachSubmissionInfo($, element); + + const tmpStatus = unknownStatus as keyof typeof BojResultsToStatus; if (bojSolutionId === '') { bojSolutionId = tmpBojSolutionId; @@ -161,7 +169,7 @@ export class SubmissionService { } // 일단 미제출 문제에 대해서는 값을 리턴하지 않음 - async getRoomSubmission({ roomCode }) { + async getRoomSubmission({ roomCode }: { roomCode: string }) { const room = await this.roomService.findRoomByCode(roomCode); const subQuery = this.submissionRepository @@ -171,7 +179,7 @@ export class SubmissionService { .addSelect('MAX(submitted_at) as submitted_at') .groupBy('user_id, problem_id'); - const sumbissions = await this.submissionRepository + const submissions = await this.submissionRepository .createQueryBuilder('submission') .where(`(user_id, problem_id, submitted_at) IN (${subQuery.getQuery()})`) .andWhere('room_id = :id', { id: room.id }) @@ -180,10 +188,69 @@ export class SubmissionService { .leftJoinAndSelect('submission.problem', 'problem') .getMany(); - return sumbissions.map((submission) => ({ + return submissions.map((submission) => ({ username: submission.user!.username, bojProblemId: submission.problem!.bojProblemId, status: submission.status, })); } + + async getSubmissionsByRoomCodeGroupByUsers( + roomCode: string, + ): Promise { + const qb = this.submissionRepository + .createQueryBuilder('s') + .innerJoin('s.room', 'r', 'r.code = :roomCode', { roomCode }) + .innerJoin('s.user', 'u') + .where('s.status = :status', { status: Status.ACCEPTED }) + .addSelect('u.username', 'username') + .addSelect('COUNT(*)', 'numberOfProblemsSolved') + .addSelect('MAX(s.submittedAt)', 'mostRecentCorrectSubmissionTime'); + + this.explainQuery(...qb.getQueryAndParameters()); + + const results = await qb.getRawMany(); + results.forEach((res) => { + res.numberOfProblemsSolved = Number(res.numberOfProblemsSolved); + }); + return results; + } + + async getUsersRankingByRoomCode(code: string): Promise { + const qb = this.entityManager + .createQueryBuilder(Room, 'room') + .where('room.code = :code', { code }) + .select('user.username', 'username') + .addSelect('COUNT(*)', 'numberOfProblemsSolved') + .addSelect( + 'MAX(submission.submittedAt)', + 'mostRecentCorrectSubmissionTime', + ) + .innerJoin('room.joinedUsers', 'roomUser') + .innerJoin('roomUser.user', 'user') + .leftJoin( + 'user.submissions', + 'submission', + 'submission.status = :status', + { status: Status.ACCEPTED }, + ) + .groupBy('user.id') + .orderBy('COUNT(submission.id)', 'DESC') + .addOrderBy('MAX(submission.submittedAt)', 'ASC'); + + this.explainQuery(...qb.getQueryAndParameters()); + + const results = await qb.getRawMany(); + + results.forEach((res) => { + res.numberOfProblemsSolved = Number(res.numberOfProblemsSolved); + }); + + return results; + } + + async explainQuery(query: string, param: any[]) { + const res = await this.entityManager.query(`EXPLAIN ${query}`, param); + this.logger.debug(res); + } } diff --git a/server/test/jest-e2e.json b/server/test/jest-e2e.json index e9d912f..986c52d 100644 --- a/server/test/jest-e2e.json +++ b/server/test/jest-e2e.json @@ -1,6 +1,10 @@ { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", + "moduleNameMapper": { + "src/(.*)$": "/../src/$1", + "test/(.*)$": "/$1" + }, "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { diff --git a/server/tsconfig.json b/server/tsconfig.json index 3155061..a3a7725 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -9,6 +9,7 @@ "target": "ES2021", "sourceMap": true, "outDir": "./dist", + "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true, @@ -16,4 +17,4 @@ "strictBindCallApply": false, "strict": true } -} +} \ No newline at end of file