diff --git a/src/Entity/user.ts b/src/Entity/user.ts index ea6e4bb..946b90a 100644 --- a/src/Entity/user.ts +++ b/src/Entity/user.ts @@ -1,7 +1,6 @@ import { Column, CreateDateColumn, - DeleteDateColumn, Entity, OneToOne, PrimaryGeneratedColumn, diff --git a/src/app.module.ts b/src/app.module.ts index ae53ce8..39f3272 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,8 +11,6 @@ import * as process from 'process'; import { HttpLoggerInterceptor } from './utils/httpLoggerInterceptor'; import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; import { HttpExceptionFilter } from './utils/httpExceptionFilter'; -import { WinstonModule } from 'nest-winston'; -import { transports } from 'winston'; @Module({ imports: [ diff --git a/src/stat/dto/rank-list-option.dto.ts b/src/stat/dto/rank-list-option.dto.ts index 8850715..85e3d01 100644 --- a/src/stat/dto/rank-list-option.dto.ts +++ b/src/stat/dto/rank-list-option.dto.ts @@ -21,7 +21,7 @@ export class RankListDto { rank: number; @ApiProperty() - user_id: string; + userId: string; @ApiProperty() point: number; diff --git a/src/stat/dto/stat-find.dto.ts b/src/stat/dto/stat-find.dto.ts index d8681d4..2d18367 100644 --- a/src/stat/dto/stat-find.dto.ts +++ b/src/stat/dto/stat-find.dto.ts @@ -9,4 +9,7 @@ export class StatFindDto { @ApiProperty() grade: number; + + @ApiProperty() + totalPoint: number; } diff --git a/src/stat/rank.controller.ts b/src/stat/rank.controller.ts index b477348..61d053d 100644 --- a/src/stat/rank.controller.ts +++ b/src/stat/rank.controller.ts @@ -52,7 +52,81 @@ export class RankController { return await this.algorithmService.getAlgorithms(options); } - @Get('/users/:id') + @Get('github') + @ApiTags('rank') + @ApiOperation({ + summary: '깃허브 전체 랭킹 API', + description: + '깃허브 역량의 랭킹리스트를 반환한다. 페이지네이션이 가능하고 학과 별로 필터링 가능하다. (학번 필터링은 아직 미구현) 커서 사용시 cursorPoint, cursorUserId 두개를 동시에 넣어야한다. 각각 마지막으로 받은 유저의 점수와 유저 아이디이다.', + }) + @ApiBearerAuth('accessToken') + @ApiOkResponse({ + description: '깃허브 랭킹 반환', + type: [RankListDto], + }) + @ApiUnauthorizedResponse({ + description: 'jwt 관련 문제 (인증 시간이 만료됨, jwt를 보내지 않음)', + }) + @ApiForbiddenResponse({ + description: '허용되지 않은 자원에 접근한 경우. 즉, 권한이 없는 경우', + }) + @ApiInternalServerErrorResponse({ + description: '서버 오류', + }) + async findGithubRank(@Query() options: RankListOptionDto) { + return await this.githubService.getGithubRank(options); + } + + @Get('grade') + @ApiTags('rank') + @ApiOperation({ + summary: '학점 전체 랭킹 API', + description: + '학점 역량의 랭킹리스트를 반환한다. 페이지네이션이 가능하고 학과 별로 필터링 가능하다. (학번 필터링은 아직 미구현) 커서 사용시 cursorPoint, cursorUserId 두개를 동시에 넣어야한다. 각각 마지막으로 받은 유저의 점수와 유저 아이디이다.', + }) + @ApiBearerAuth('accessToken') + @ApiOkResponse({ + description: '깃허브 랭킹 반환', + type: [RankListDto], + }) + @ApiUnauthorizedResponse({ + description: 'jwt 관련 문제 (인증 시간이 만료됨, jwt를 보내지 않음)', + }) + @ApiForbiddenResponse({ + description: '허용되지 않은 자원에 접근한 경우. 즉, 권한이 없는 경우', + }) + @ApiInternalServerErrorResponse({ + description: '서버 오류', + }) + async findGradeRank(@Query() options: RankListOptionDto) { + return await this.gradeService.getGradeRank(options); + } + + @Get('total') + @ApiTags('rank') + @ApiOperation({ + summary: '종합 전체 랭킹 API', + description: + '학점 역량의 랭킹리스트를 반환한다. 페이지네이션이 가능하고 학과 별로 필터링 가능하다. (학번 필터링은 아직 미구현) 커서 사용시 cursorPoint, cursorUserId 두개를 동시에 넣어야한다. 각각 마지막으로 받은 유저의 점수와 유저 아이디이다.', + }) + @ApiBearerAuth('accessToken') + @ApiOkResponse({ + description: '종합 랭킹 반환', + type: [RankListDto], + }) + @ApiUnauthorizedResponse({ + description: 'jwt 관련 문제 (인증 시간이 만료됨, jwt를 보내지 않음)', + }) + @ApiForbiddenResponse({ + description: '허용되지 않은 자원에 접근한 경우. 즉, 권한이 없는 경우', + }) + @ApiInternalServerErrorResponse({ + description: '서버 오류', + }) + async findTotalRank(@Query() options: RankListOptionDto) { + return await this.totalService.getTotalRank(options); + } + @ApiTags('rank') @ApiOperation({ summary: '유저의 각 부문 별 랭킹 API', @@ -73,10 +147,14 @@ export class RankController { @ApiInternalServerErrorResponse({ description: '서버 오류', }) - async findUsersRank(@Param('id') userId, @Query() options: PointFindDto) { + @Get('/users/:id') + async findUsersRank( + @Param('id') userId: string, + @Query() options: PointFindDto, + ) { const user = await this.userService.findUserByUserId(userId); - if (options && user.major !== options.major) { + if (options.major && user.major !== options.major) { return new RankFindDto(null, null, null, null); } else { const algorithmRank = diff --git a/src/stat/repository/algorithm.repository.ts b/src/stat/repository/algorithm.repository.ts index 5f0eee7..1ec932c 100644 --- a/src/stat/repository/algorithm.repository.ts +++ b/src/stat/repository/algorithm.repository.ts @@ -1,19 +1,16 @@ -import { BaseRepository } from '../../utils/base.repository'; import { Inject, Injectable, Scope } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource } from 'typeorm'; import { REQUEST } from '@nestjs/core'; import { Algorithm } from '../../Entity/algorithm'; -import { RankListDto, RankListOptionDto } from '../dto/rank-list-option.dto'; +import { RankListOptionDto } from '../dto/rank-list-option.dto'; import { User } from '../../Entity/user'; -import { RankController } from '../rank.controller'; import { PointFindDto } from '../dto/rank-find.dto'; +import { StatRepository } from '../../utils/stat.repository'; @Injectable({ scope: Scope.REQUEST }) -export class AlgorithmRepository extends BaseRepository { - private repository: Repository; +export class AlgorithmRepository extends StatRepository { constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { - super(dataSource, req); - this.repository = this.getRepository(Algorithm); + super(dataSource, req, Algorithm); } async save(algorithm: Algorithm) { @@ -23,31 +20,6 @@ export class AlgorithmRepository extends BaseRepository { async findOneById(userId: string) { return await this.repository.findOneBy({ userId: userId }); } - - async findAlgorithmWithRank( - options: RankListOptionDto, - ): Promise<[RankListDto]> { - const queryBuilder = this.repository - .createQueryBuilder() - .select(['b.rank', 'b.user_id', 'b.point', 'b.nickname']) - .distinct(true) - .from((sub) => { - return sub - .select('RANK() OVER (ORDER BY a.point DESC)', 'rank') - .addSelect('a.user_id', 'user_id') - .addSelect('a.point', 'point') - .addSelect('u.nickname', 'nickname') - .from(Algorithm, 'a') - .innerJoin(User, 'u', 'a.user_id = u.user_id') - .where(this.createClassificationOption(options)); - }, 'b') - .where(this.createCursorOption(options)) - .orderBy('point', 'DESC') - .addOrderBy('user_id') - .limit(3); - return await (>queryBuilder.getRawMany()); - } - public async findIndividualAlgorithmRank( userId: string, options: PointFindDto, @@ -66,7 +38,7 @@ export class AlgorithmRepository extends BaseRepository { .addSelect('u.major', 'major') .where(this.createClassificationOption(options)); }, 'b') - .where(`b.user_id = ${userId}`); + .where(`b.user_id = '${userId}'`); return await queryBuilder.getRawOne(); } diff --git a/src/stat/repository/github.repository.ts b/src/stat/repository/github.repository.ts index ed44bcd..31216ec 100644 --- a/src/stat/repository/github.repository.ts +++ b/src/stat/repository/github.repository.ts @@ -1,20 +1,13 @@ -import { BaseRepository } from '../../utils/base.repository'; -import { Inject, Injectable, Scope } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Github } from '../../Entity/github'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource } from 'typeorm'; import { REQUEST } from '@nestjs/core'; -import { RankListOptionDto } from '../dto/rank-list-option.dto'; -import { Algorithm } from '../../Entity/algorithm'; -import { User } from '../../Entity/user'; -import { PointFindDto } from '../dto/rank-find.dto'; +import { StatRepository } from '../../utils/stat.repository'; @Injectable() -export class GithubRepository extends BaseRepository { - private repository: Repository; - +export class GithubRepository extends StatRepository { constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { - super(dataSource, req); - this.repository = this.getRepository(Github); + super(dataSource, req, Github); } public async save(github: Github) { @@ -43,34 +36,4 @@ export class GithubRepository extends BaseRepository { public async findAll() { return await this.repository.find(); } - - public async findIndividualGithubRank( - userId: string, - options: PointFindDto, - ) { - const queryBuilder = this.repository - .createQueryBuilder() - .select(['b.rank', 'b.user_id']) - .distinct(true) - .from((sub) => { - return sub - .select('RANK() OVER (ORDER BY g.point DESC)', 'rank') - .addSelect('g.user_id', 'user_id') - .addSelect('g.point', 'point') - .from(Github, 'g') - .innerJoin(User, 'u', 'g.user_id = u.user_id') - .where(this.createClassificationOption(options)); - }, 'b') - .where(`b.user_id = ${userId}`); - - return await queryBuilder.getRawOne(); - } - - createClassificationOption(options: PointFindDto) { - if (options.major != null) { - return `u.major like '${options.major}'`; - } else { - return `u.id > 0`; - } - } } diff --git a/src/stat/repository/grade.repository.ts b/src/stat/repository/grade.repository.ts index d7cd156..2643d45 100644 --- a/src/stat/repository/grade.repository.ts +++ b/src/stat/repository/grade.repository.ts @@ -1,20 +1,13 @@ -import { BaseRepository } from '../../utils/base.repository'; import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; -import { Github } from '../../Entity/github'; +import { DataSource } from 'typeorm'; import { REQUEST } from '@nestjs/core'; import { Grade } from '../../Entity/grade'; -import { RankListOptionDto } from '../dto/rank-list-option.dto'; -import { User } from '../../Entity/user'; -import { PointFindDto } from '../dto/rank-find.dto'; +import { StatRepository } from '../../utils/stat.repository'; @Injectable() -export class GradeRepository extends BaseRepository { - private repository: Repository; - +export class GradeRepository extends StatRepository { constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { - super(dataSource, req); - this.repository = this.getRepository(Grade); + super(dataSource, req, Grade); } public async save(grade: Grade) { @@ -38,34 +31,4 @@ export class GradeRepository extends BaseRepository { public async delete(id: string) { await this.repository.delete({ userId: id }); } - - public async findIndividualGradeRank( - userId: string, - options: PointFindDto, - ) { - const queryBuilder = this.repository - .createQueryBuilder() - .select(['b.rank', 'b.user_id']) - .distinct(true) - .from((sub) => { - return sub - .select('RANK() OVER (ORDER BY g.point DESC)', 'rank') - .addSelect('g.user_id', 'user_id') - .addSelect('g.point', 'point') - .from(Grade, 'g') - .innerJoin(User, 'u', 'g.user_id = u.user_id') - .where(this.createClassificationOption(options)); - }, 'b') - .where(`b.user_id = ${userId}`); - - return await queryBuilder.getRawOne(); - } - - createClassificationOption(options: PointFindDto) { - if (options.major != null) { - return `u.major like '${options.major}'`; - } else { - return `u.id > 0`; - } - } } diff --git a/src/stat/repository/total.repository.ts b/src/stat/repository/total.repository.ts index fd609a7..1977e62 100644 --- a/src/stat/repository/total.repository.ts +++ b/src/stat/repository/total.repository.ts @@ -1,20 +1,12 @@ -import { BaseRepository } from '../../utils/base.repository'; -import { DataSource, Repository } from 'typeorm'; -import { Grade } from '../../Entity/grade'; +import { DataSource } from 'typeorm'; import { Inject } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { TotalPoint } from '../../Entity/totalPoint'; -import { RankListOptionDto } from '../dto/rank-list-option.dto'; -import { Algorithm } from '../../Entity/algorithm'; -import { User } from '../../Entity/user'; -import { PointFindDto } from '../dto/rank-find.dto'; - -export class TotalRepository extends BaseRepository { - private repository: Repository; +import { StatRepository } from '../../utils/stat.repository'; +export class TotalRepository extends StatRepository { constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { - super(dataSource, req); - this.repository = this.getRepository(TotalPoint); + super(dataSource, req, TotalPoint); } async findOneById(userId: string) { @@ -28,35 +20,4 @@ export class TotalRepository extends BaseRepository { async save(total: TotalPoint) { return await this.repository.save(total); } - - public async findIndividualAlgorithmRank( - userId: string, - options: PointFindDto, - ) { - const queryBuilder = this.repository - .createQueryBuilder() - .select(['b.rank', 'b.user_id', 'b.major']) - .distinct(true) - .from((sub) => { - return sub - .select('RANK() OVER (ORDER BY a.point DESC)', 'rank') - .addSelect('a.user_id', 'user_id') - .addSelect('a.point', 'point') - .from(Algorithm, 'a') - .innerJoin(User, 'u', 'a.user_id = u.user_id') - .addSelect('u.major', 'major') - .where(this.createClassificationOption(options)); - }, 'b') - .where(`b.user_id = ${userId}`); - - return await queryBuilder.getRawOne(); - } - - createClassificationOption(options: PointFindDto) { - if (options.major != null) { - return `u.major like '${options.major}'`; - } else { - return `u.id > 0`; - } - } } diff --git a/src/stat/service/algorithm.service.spec.ts b/src/stat/service/algorithm.service.spec.ts index 5394688..5f40182 100644 --- a/src/stat/service/algorithm.service.spec.ts +++ b/src/stat/service/algorithm.service.spec.ts @@ -3,12 +3,14 @@ import { AlgorithmService } from './algorithm.service'; import axios from 'axios'; import { AlgorithmRepository } from '../repository/algorithm.repository'; import { Algorithm } from '../../Entity/algorithm'; +import { RankListOptionDto } from '../dto/rank-list-option.dto'; const mockAlgorithmRepository = { save: jest.fn(), findOneById: jest.fn(), update: jest.fn(), delete: jest.fn(), + findAlgorithmWithRank: jest.fn(), }; jest.mock('axios'); @@ -222,4 +224,25 @@ describe('AlgorithmService', () => { ); }); }); + + describe('getAlgorithms', function () { + it('cursor 를 하나만 보내면 오류를 던진다.', function () { + const options1: RankListOptionDto = { + cursorUserId: null, + major: null, + cursorPoint: 12, + }; + const options2: RankListOptionDto = { + cursorUserId: 'qwe', + major: null, + cursorPoint: null, + }; + expect(service.getAlgorithms(options1)).rejects.toThrow( + 'Cursor Element Must Be Two', + ); + expect(service.getAlgorithms(options2)).rejects.toThrow( + 'Cursor Element Must Be Two', + ); + }); + }); }); diff --git a/src/stat/service/algorithm.service.ts b/src/stat/service/algorithm.service.ts index 89f1080..8a9c32e 100644 --- a/src/stat/service/algorithm.service.ts +++ b/src/stat/service/algorithm.service.ts @@ -34,7 +34,7 @@ export class AlgorithmService { ) { throw new BadRequestException('Cursor Element Must Be Two'); } - return await this.algorithmRepository.findAlgorithmWithRank(options); + return await this.algorithmRepository.findWithRank(options); } async createAlgorithm(userId: string, bojId: string) { @@ -123,7 +123,7 @@ export class AlgorithmService { userId: string, options: PointFindDto, ) { - return await this.algorithmRepository.findIndividualAlgorithmRank( + return await this.algorithmRepository.findIndividualRank( userId, options, ); diff --git a/src/stat/service/github.service.ts b/src/stat/service/github.service.ts index 0e485f2..5a7d0f8 100644 --- a/src/stat/service/github.service.ts +++ b/src/stat/service/github.service.ts @@ -3,7 +3,6 @@ import { Injectable, InternalServerErrorException, Logger, - NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { GithubRepository } from '../repository/github.repository'; @@ -135,10 +134,7 @@ export class GithubService { userId: string, options: PointFindDto, ) { - return await this.githubRepository.findIndividualGithubRank( - userId, - options, - ); + return await this.githubRepository.findIndividualRank(userId, options); } // public async redirect(code: string) { @@ -160,4 +156,13 @@ export class GithubService { // github.githubId = userResource.id; // await this.githubRepository.save(github); // } + async getGithubRank(options: RankListOptionDto) { + if ( + (options.cursorPoint && !options.cursorUserId) || + (!options.cursorPoint && options.cursorUserId) + ) { + throw new BadRequestException('Cursor Element Must Be Two'); + } + return await this.githubRepository.findWithRank(options); + } } diff --git a/src/stat/service/grade.service.ts b/src/stat/service/grade.service.ts index c680eb1..88f8ed5 100644 --- a/src/stat/service/grade.service.ts +++ b/src/stat/service/grade.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { GradeRepository } from '../repository/grade.repository'; import { Grade } from '../../Entity/grade'; -import { RankListOptionDto } from '../dto/rank-list-option.dto'; +import { RankListDto, RankListOptionDto } from '../dto/rank-list-option.dto'; import { PointFindDto } from '../dto/rank-find.dto'; @Injectable() @@ -60,9 +60,16 @@ export class GradeService { } public async getIndividualGradeRank(userId: string, options: PointFindDto) { - return await this.gradeRepository.findIndividualGradeRank( - userId, - options, - ); + return await this.gradeRepository.findIndividualRank(userId, options); + } + + async getGradeRank(options: RankListOptionDto): Promise<[RankListDto]> { + if ( + (options.cursorPoint && !options.cursorUserId) || + (!options.cursorPoint && options.cursorUserId) + ) { + throw new BadRequestException('Cursor Element Must Be Two'); + } + return await this.gradeRepository.findWithRank(options); } } diff --git a/src/stat/service/total.service.ts b/src/stat/service/total.service.ts index 7e78597..269474a 100644 --- a/src/stat/service/total.service.ts +++ b/src/stat/service/total.service.ts @@ -7,8 +7,9 @@ import { GradeService } from './grade.service'; import { Github } from '../../Entity/github'; import { Grade } from '../../Entity/grade'; import { Algorithm } from '../../Entity/algorithm'; -import { RankListOptionDto } from '../dto/rank-list-option.dto'; +import { RankListDto, RankListOptionDto } from '../dto/rank-list-option.dto'; import { PointFindDto } from '../dto/rank-find.dto'; +import { StatFindDto } from '../dto/stat-find.dto'; @Injectable() export class TotalService { @@ -19,17 +20,30 @@ export class TotalService { private gradeService: GradeService, ) {} - async findStat(userId: string) { + async findStat(userId: string): Promise { const github = await this.githubService.findGithub(userId); const algorithm = await this.algorithmService.findAlgorithm(userId); const grade = await this.gradeService.findGrade(userId); + const total = await this.totalRepository.findOneById(userId); return { githubPoint: github ? github.point : null, algorithmPoint: algorithm ? algorithm.point : null, grade: grade ? grade.grade : null, + totalPoint: grade ? total.point : null, }; } + + async getTotalRank(options: RankListOptionDto): Promise<[RankListDto]> { + if ( + (options.cursorPoint && !options.cursorUserId) || + (!options.cursorPoint && options.cursorUserId) + ) { + throw new BadRequestException('Cursor Element Must Be Two'); + } + return await this.totalRepository.findWithRank(options); + } + async createTotalPoint(userId: string) { const isExist = await this.totalRepository.findOneById(userId); @@ -57,9 +71,6 @@ export class TotalService { } public async getIndividualTotalRank(userId: string, options: PointFindDto) { - return await this.totalRepository.findIndividualAlgorithmRank( - userId, - options, - ); + return await this.totalRepository.findIndividualRank(userId, options); } } diff --git a/src/utils/stat.repository.ts b/src/utils/stat.repository.ts new file mode 100644 index 0000000..f7b5982 --- /dev/null +++ b/src/utils/stat.repository.ts @@ -0,0 +1,75 @@ +import { BaseRepository } from './base.repository'; +import { DataSource, Repository } from 'typeorm'; +import { + RankListDto, + RankListOptionDto, +} from '../stat/dto/rank-list-option.dto'; +import { User } from '../Entity/user'; +import { PointFindDto } from '../stat/dto/rank-find.dto'; + +export class StatRepository extends BaseRepository { + protected repository: Repository; + private entity; + constructor(dataSource: DataSource, req: Request, entity) { + super(dataSource, req); + this.repository = this.getRepository(entity); + this.entity = entity; + } + + async findWithRank(options: RankListOptionDto): Promise<[RankListDto]> { + const queryBuilder = this.repository + .createQueryBuilder() + .select(['b.rank', 'b.point', 'b.nickname']) + .addSelect('b.user_id', 'userId') + .distinct(true) + .from((sub) => { + return sub + .select('RANK() OVER (ORDER BY a.point DESC)', 'rank') + .addSelect('a.user_id', 'user_id') + .addSelect('a.point', 'point') + .addSelect('u.nickname', 'nickname') + .from(this.entity, 'a') + .innerJoin(User, 'u', 'a.user_id = u.user_id') + .where(this.createClassificationOption(options)); + }, 'b') + .where(this.createCursorOption(options)) + .orderBy('point', 'DESC') + .addOrderBy('userId') + .limit(3); + return await (>queryBuilder.getRawMany()); + } + + public async findIndividualRank(userId: string, options: PointFindDto) { + const queryBuilder = this.repository + .createQueryBuilder() + .select(['b.rank', 'b.user_id']) + .distinct(true) + .from((sub) => { + return sub + .select('RANK() OVER (ORDER BY a.point DESC)', 'rank') + .addSelect('a.user_id', 'user_id') + .from(this.entity, 'a') + .innerJoin(User, 'u', 'a.user_id = u.user_id') + .addSelect('u.major', 'major') + .where(this.createClassificationOption(options)); + }, 'b') + .where(`b.user_id = '${userId}'`); + + return await queryBuilder.getRawOne(); + } + createCursorOption(options: RankListOptionDto) { + if (!options.cursorPoint && !options.cursorUserId) { + return 'b.point > -1'; + } else { + return `b.point < ${options.cursorPoint} or b.point = ${options.cursorPoint} AND b.user_id > '${options.cursorUserId}'`; + } + } + + createClassificationOption(options: PointFindDto) { + if (options.major != null) { + return `u.major like '${options.major}'`; + } else { + return `u.id > 0`; + } + } +}