diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0e4115f40..373d37fefc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,7 +155,7 @@ jobs: working-directory: ./apps/iris - name: Lint (Node.js) - run: git diff --name-only --diff-filter=ACMRUXB origin/main | grep -E "(.ts$|.tsx$|.js$|.jsx$)" | xargs -r pnpm eslint + run: git diff --name-only --diff-filter=ACMRUXB origin/main | grep -E "(.ts$|.tsx$|.js$|.jsx$)" | grep -v 'next.config.js$' | xargs -r pnpm eslint test-backend: name: Test Backend diff --git a/apps/backend/apps/admin/src/contest/contest.resolver.ts b/apps/backend/apps/admin/src/contest/contest.resolver.ts index f32b10d874..688765328f 100644 --- a/apps/backend/apps/admin/src/contest/contest.resolver.ts +++ b/apps/backend/apps/admin/src/contest/contest.resolver.ts @@ -14,7 +14,7 @@ import { ContestSubmissionSummaryForUser } from './model/contest-submission-summ import { ContestWithParticipants } from './model/contest-with-participants.model' import { CreateContestInput } from './model/contest.input' import { UpdateContestInput } from './model/contest.input' -import { ContestsGroupedByStatus } from './model/contests-grouped-by-status' +import { ContestsGroupedByStatus } from './model/contests-grouped-by-status.output' import { DuplicatedContestResponse } from './model/duplicated-contest-response.output' import { ProblemScoreInput } from './model/problem-score.input' import { PublicizingRequest } from './model/publicizing-request.model' diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index da72b165fe..0f07fe150a 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.ts @@ -20,6 +20,7 @@ import { UnprocessableDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' +import type { ContestWithScores } from './model/contest-with-scores.model' import type { CreateContestInput } from './model/contest.input' import type { UpdateContestInput } from './model/contest.input' import type { ProblemScoreInput } from './model/problem-score.input' @@ -379,11 +380,6 @@ export class ContestService { if (!contest) { throw new EntityNotExistException('contest') } - if (contest.submission.length) { - throw new UnprocessableDataException( - 'Cannot import problems if submission exists' - ) - } const contestProblems: ContestProblem[] = [] @@ -467,11 +463,6 @@ export class ContestService { if (!contest) { throw new EntityNotExistException('contest') } - if (contest.submission.length) { - throw new UnprocessableDataException( - 'Cannot delete problems if submission exists' - ) - } const contestProblems: ContestProblem[] = [] @@ -708,7 +699,7 @@ export class ContestService { * 특정 user의 특정 Contest에 대한 총점, 통과한 문제 개수와 각 문제별 테스트케이스 통과 개수를 불러옵니다. */ async getContestScoreSummary(userId: number, contestId: number) { - const [contestProblems, submissions] = await Promise.all([ + const [contestProblems, rawSubmissions] = await Promise.all([ this.prisma.contestProblem.findMany({ where: { contestId @@ -725,6 +716,14 @@ export class ContestService { }) ]) + // 오직 현재 Contest에 남아있는 문제들의 제출에 대해서만 ScoreSummary 계산 + const contestProblemIds = contestProblems.map( + (contestProblem) => contestProblem.problemId + ) + const submissions = rawSubmissions.filter((submission) => + contestProblemIds.includes(submission.problemId) + ) + if (!submissions.length) { return { submittedProblemCount: 0, @@ -856,7 +855,8 @@ export class ContestService { select: { realName: true } - } + }, + major: true } } }, @@ -874,6 +874,7 @@ export class ContestService { username: record.user?.username, studentId: record.user?.studentId, realName: record.user?.userProfile?.realName, + major: record.user?.major, ...(await this.getContestScoreSummary( record.userId as number, contestId @@ -891,7 +892,8 @@ export class ContestService { problemId }, select: { - contest: true + contest: true, + score: true } }) @@ -899,8 +901,16 @@ export class ContestService { throw new EntityNotExistException('Problem or ContestProblem') } - const contests = contestProblems.map( - (contestProblem) => contestProblem.contest + const contests = await Promise.all( + contestProblems.map(async (contestProblem) => { + return { + ...contestProblem.contest, + problemScore: contestProblem.score, + totalScore: await this.getTotalScoreOfContest( + contestProblem.contest.id + ) + } + }) ) const now = new Date() @@ -919,12 +929,28 @@ export class ContestService { return acc }, { - upcoming: [] as Contest[], - ongoing: [] as Contest[], - finished: [] as Contest[] + upcoming: [] as ContestWithScores[], + ongoing: [] as ContestWithScores[], + finished: [] as ContestWithScores[] } ) return contestsGroupedByStatus } + + async getTotalScoreOfContest(contestId: number) { + const contestProblemScores = await this.prisma.contestProblem.findMany({ + where: { + contestId + }, + select: { + score: true + } + }) + + return contestProblemScores.reduce( + (total, problem) => total + problem.score, + 0 + ) + } } diff --git a/apps/backend/apps/admin/src/contest/model/contest-with-scores.model.ts b/apps/backend/apps/admin/src/contest/model/contest-with-scores.model.ts new file mode 100644 index 0000000000..da27d3f566 --- /dev/null +++ b/apps/backend/apps/admin/src/contest/model/contest-with-scores.model.ts @@ -0,0 +1,11 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Contest } from '@admin/@generated' + +@ObjectType() +export class ContestWithScores extends Contest { + @Field(() => Int) + problemScore: number + + @Field(() => Int) + totalScore: number +} diff --git a/apps/backend/apps/admin/src/contest/model/contests-grouped-by-status.output.ts b/apps/backend/apps/admin/src/contest/model/contests-grouped-by-status.output.ts new file mode 100644 index 0000000000..d2c62ee672 --- /dev/null +++ b/apps/backend/apps/admin/src/contest/model/contests-grouped-by-status.output.ts @@ -0,0 +1,14 @@ +import { Field, ObjectType } from '@nestjs/graphql' +import { ContestWithScores } from './contest-with-scores.model' + +@ObjectType() +export class ContestsGroupedByStatus { + @Field(() => [ContestWithScores]) + upcoming: ContestWithScores[] + + @Field(() => [ContestWithScores]) + ongoing: ContestWithScores[] + + @Field(() => [ContestWithScores]) + finished: ContestWithScores[] +} diff --git a/apps/backend/apps/admin/src/contest/model/contests-grouped-by-status.ts b/apps/backend/apps/admin/src/contest/model/contests-grouped-by-status.ts deleted file mode 100644 index 8765f38975..0000000000 --- a/apps/backend/apps/admin/src/contest/model/contests-grouped-by-status.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Field, ObjectType } from '@nestjs/graphql' -import { Contest } from '@admin/@generated' - -@ObjectType() -export class ContestsGroupedByStatus { - @Field(() => [Contest]) - upcoming: Contest[] - - @Field(() => [Contest]) - ongoing: Contest[] - - @Field(() => [Contest]) - finished: Contest[] -} diff --git a/apps/backend/apps/admin/src/contest/model/score-summary.ts b/apps/backend/apps/admin/src/contest/model/score-summary.ts index f53b6e2ea3..128f2b8b76 100644 --- a/apps/backend/apps/admin/src/contest/model/score-summary.ts +++ b/apps/backend/apps/admin/src/contest/model/score-summary.ts @@ -32,6 +32,9 @@ export class UserContestScoreSummaryWithUserInfo { @Field(() => String, { nullable: true }) realName: string + @Field(() => String) + major: string + @Field(() => Int) submittedProblemCount: number diff --git a/apps/backend/apps/admin/src/problem/problem.resolver.ts b/apps/backend/apps/admin/src/problem/problem.resolver.ts index 0f3234b435..102d07757f 100644 --- a/apps/backend/apps/admin/src/problem/problem.resolver.ts +++ b/apps/backend/apps/admin/src/problem/problem.resolver.ts @@ -34,6 +34,7 @@ import { UnprocessableDataException } from '@libs/exception' import { CursorValidationPipe, GroupIDPipe, RequiredIntPipe } from '@libs/pipe' +import { ProblemScoreInput } from '@admin/contest/model/problem-score.input' import { ImageSource } from './model/image.output' import { CreateProblemInput, @@ -293,6 +294,20 @@ export class ContestProblemResolver { } } + @Mutation(() => [ContestProblem]) + async updateContestProblemsScore( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('contestId', { type: () => Int }) contestId: number, + @Args('problemIdsWithScore', { type: () => [ProblemScoreInput] }) + problemIdsWithScore: ProblemScoreInput[] + ) { + return await this.problemService.updateContestProblemsScore( + groupId, + contestId, + problemIdsWithScore + ) + } + @Mutation(() => [ContestProblem]) async updateContestProblemsOrder( @Args( diff --git a/apps/backend/apps/admin/src/problem/problem.service.ts b/apps/backend/apps/admin/src/problem/problem.service.ts index 16dfe025d7..e86b0d2da9 100644 --- a/apps/backend/apps/admin/src/problem/problem.service.ts +++ b/apps/backend/apps/admin/src/problem/problem.service.ts @@ -23,6 +23,7 @@ import { UnprocessableFileDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' +import type { ProblemScoreInput } from '@admin/contest/model/problem-score.input' import { StorageService } from '@admin/storage/storage.service' import { ImportedProblemHeader } from './model/problem.constants' import type { @@ -603,6 +604,31 @@ export class ProblemService { return contestProblems } + async updateContestProblemsScore( + groupId: number, + contestId: number, + problemIdsWithScore: ProblemScoreInput[] + ): Promise[]> { + await this.prisma.contest.findFirstOrThrow({ + where: { id: contestId, groupId } + }) + + const queries = problemIdsWithScore.map((record) => { + return this.prisma.contestProblem.update({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestId_problemId: { + contestId, + problemId: record.problemId + } + }, + data: { score: record.score } + }) + }) + + return await this.prisma.$transaction(queries) + } + async updateContestProblemsOrder( groupId: number, contestId: number, diff --git a/apps/backend/apps/admin/src/user/user.resolver.ts b/apps/backend/apps/admin/src/user/user.resolver.ts index 74af703590..a23a2e4802 100644 --- a/apps/backend/apps/admin/src/user/user.resolver.ts +++ b/apps/backend/apps/admin/src/user/user.resolver.ts @@ -1,14 +1,6 @@ -import { - BadRequestException, - InternalServerErrorException, - ConflictException, - Logger, - NotFoundException -} from '@nestjs/common' import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql' import { UserGroup } from '@generated' import { User } from '@generated' -import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' import { OPEN_SPACE_ID } from '@libs/constants' import { CursorValidationPipe, GroupIDPipe, RequiredIntPipe } from '@libs/pipe' import { GroupMember } from './model/groupMember.model' @@ -17,7 +9,6 @@ import { UserService } from './user.service' @Resolver(() => User) export class UserResolver { constructor(private readonly userService: UserService) {} - private readonly logger = new Logger(UserResolver.name) @Query(() => [GroupMember]) async getGroupMembers( @@ -56,16 +47,7 @@ export class UserResolver { @Args('userId', { type: () => Int }, new RequiredIntPipe('userId')) userId: number ) { - try { - return await this.userService.getGroupMember(groupId, userId) - } catch (error) { - if ( - error instanceof PrismaClientKnownRequestError && - error.code == 'P2025' - ) { - throw new NotFoundException(error.message) - } - } + return await this.userService.getGroupMember(groupId, userId) } @Mutation(() => UserGroup) @@ -75,21 +57,11 @@ export class UserResolver { @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, @Args('toGroupLeader') toGroupLeader: boolean ) { - try { - return await this.userService.updateGroupRole( - userId, - groupId, - toGroupLeader - ) - } catch (error) { - if (error instanceof BadRequestException) { - throw new BadRequestException(error.message) - } else if (error instanceof NotFoundException) { - throw new NotFoundException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.userService.updateGroupRole( + userId, + groupId, + toGroupLeader + ) } @Mutation(() => UserGroup) @@ -98,29 +70,14 @@ export class UserResolver { userId: number, @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number ) { - try { - return await this.userService.deleteGroupMember(userId, groupId) - } catch (error) { - if (error instanceof BadRequestException) { - throw new BadRequestException(error.message) - } else if (error instanceof NotFoundException) { - throw new NotFoundException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.userService.deleteGroupMember(userId, groupId) } @Query(() => [User]) async getJoinRequests( @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number ) { - try { - return await this.userService.getJoinRequests(groupId) - } catch (error) { - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.userService.getJoinRequests(groupId) } @Mutation(() => UserGroup) @@ -130,14 +87,6 @@ export class UserResolver { userId: number, @Args('isAccept') isAccept: boolean ) { - try { - return await this.userService.handleJoinRequest(groupId, userId, isAccept) - } catch (error) { - if (error instanceof ConflictException) { - throw new ConflictException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.userService.handleJoinRequest(groupId, userId, isAccept) } } diff --git a/apps/backend/apps/admin/src/user/user.service.ts b/apps/backend/apps/admin/src/user/user.service.ts index 23b46a1e65..6cf563b915 100644 --- a/apps/backend/apps/admin/src/user/user.service.ts +++ b/apps/backend/apps/admin/src/user/user.service.ts @@ -1,17 +1,19 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager' import { BadRequestException, - ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common' -import type { UserGroup } from '@generated' +import { UserGroup } from '@generated' import { Role } from '@prisma/client' import { Cache } from 'cache-manager' import { joinGroupCacheKey } from '@libs/cache' import { JOIN_GROUP_REQUEST_EXPIRE_TIME } from '@libs/constants' -import { EntityNotExistException } from '@libs/exception' +import { + ConflictFoundException, + EntityNotExistException +} from '@libs/exception' import { PrismaService } from '@libs/prisma' import type { GroupJoinRequest } from '@libs/types' @@ -76,7 +78,7 @@ export class UserService { } async getGroupMember(groupId: number, userId: number) { - const userGroup = await this.prisma.userGroup.findFirstOrThrow({ + const userGroup = await this.prisma.userGroup.findFirst({ where: { groupId, userId @@ -99,6 +101,9 @@ export class UserService { } } }) + if (!userGroup) { + throw new EntityNotExistException(userGroup) + } return { username: userGroup.user.username, userId: userGroup.user.id, @@ -258,7 +263,7 @@ export class UserService { const userRequested = validRequests.find((req) => req.userId === userId) if (!userRequested) { - throw new ConflictException( + throw new ConflictFoundException( `userId ${userId} didn't request join to groupId ${groupId}` ) } @@ -283,7 +288,7 @@ export class UserService { }) if (!requestedUser) { - throw new NotFoundException(`userId ${userId} not found`) + throw new EntityNotExistException(`userId ${userId}`) } return await this.prisma.userGroup.create({ diff --git a/apps/backend/apps/client/src/auth/auth.controller.ts b/apps/backend/apps/client/src/auth/auth.controller.ts index c9020ef4ab..6b8f88d584 100644 --- a/apps/backend/apps/client/src/auth/auth.controller.ts +++ b/apps/backend/apps/client/src/auth/auth.controller.ts @@ -6,8 +6,6 @@ import { Req, Res, UnauthorizedException, - InternalServerErrorException, - Logger, UseGuards } from '@nestjs/common' import { AuthGuard } from '@nestjs/passport' @@ -18,18 +16,12 @@ import { type JwtTokens } from '@libs/auth' import { REFRESH_TOKEN_COOKIE_OPTIONS } from '@libs/constants' -import { - InvalidJwtTokenException, - UnidentifiedException -} from '@libs/exception' import { AuthService } from './auth.service' import { LoginUserDto } from './dto/login-user.dto' import type { GithubUser, KakaoUser } from './interface/social-user.interface' @Controller('auth') export class AuthController { - private readonly logger = new Logger(AuthController.name) - constructor(private readonly authService: AuthService) {} setJwtResponse = (res: Response, jwtTokens: JwtTokens) => { @@ -47,16 +39,8 @@ export class AuthController { @Body() loginUserDto: LoginUserDto, @Res({ passthrough: true }) res: Response ) { - try { - const jwtTokens = await this.authService.issueJwtTokens(loginUserDto) - this.setJwtResponse(res, jwtTokens) - } catch (error) { - if (error instanceof UnidentifiedException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException('Login failed') - } + const jwtTokens = await this.authService.issueJwtTokens(loginUserDto) + this.setJwtResponse(res, jwtTokens) } @Post('logout') @@ -68,13 +52,9 @@ export class AuthController { // FIX ME: refreshToken이 없을 때 에러를 던지는 것이 맞는지 확인 // 일단은 refreshToken이 없을 때는 무시하도록 함 if (!refreshToken) return - try { - await this.authService.deleteRefreshToken(req.user.id, refreshToken) - res.clearCookie('refresh_token', REFRESH_TOKEN_COOKIE_OPTIONS) - } catch (error) { - this.logger.error(error) - throw new InternalServerErrorException() - } + + await this.authService.deleteRefreshToken(req.user.id, refreshToken) + res.clearCookie('refresh_token', REFRESH_TOKEN_COOKIE_OPTIONS) } @AuthNotNeededIfOpenSpace() @@ -86,16 +66,8 @@ export class AuthController { const refreshToken = req.cookies['refresh_token'] if (!refreshToken) throw new UnauthorizedException('Invalid Token') - try { - const newJwtTokens = await this.authService.updateJwtTokens(refreshToken) - this.setJwtResponse(res, newJwtTokens) - } catch (error) { - if (error instanceof InvalidJwtTokenException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException('Failed to reissue tokens') - } + const newJwtTokens = await this.authService.updateJwtTokens(refreshToken) + this.setJwtResponse(res, newJwtTokens) } @AuthNotNeededIfOpenSpace() @@ -113,16 +85,8 @@ export class AuthController { @Res({ passthrough: true }) res: Response, @Req() req: Request ) { - try { - const githubUser = req.user as GithubUser - return await this.authService.githubLogin(res, githubUser) - } catch (error) { - if (error instanceof UnidentifiedException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException('Login failed') - } + const githubUser = req.user as GithubUser + return await this.authService.githubLogin(res, githubUser) } /** Kakao Login page로 이동 */ @@ -141,15 +105,7 @@ export class AuthController { @Res({ passthrough: true }) res: Response, @Req() req: Request ) { - try { - const kakaoUser = req.user as KakaoUser - return await this.authService.kakaoLogin(res, kakaoUser) - } catch (error) { - if (error instanceof UnidentifiedException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException('Login failed') - } + const kakaoUser = req.user as KakaoUser + return await this.authService.kakaoLogin(res, kakaoUser) } } diff --git a/apps/backend/apps/client/src/problem/problem.service.ts b/apps/backend/apps/client/src/problem/problem.service.ts index 461d47ff48..3badd67b59 100644 --- a/apps/backend/apps/client/src/problem/problem.service.ts +++ b/apps/backend/apps/client/src/problem/problem.service.ts @@ -18,6 +18,11 @@ import { ProblemRepository } from './problem.repository' export class ProblemService { constructor(private readonly problemRepository: ProblemRepository) {} + /** + * 주어진 옵션에 따라 문제 목록을 가져옵니다. + * 문제, 태그를 가져오고 사용자가 각 문제를 통과했는지 확인합니다. + * @returns {ProblemsResponseDto} data: 문제 목록, total: 문제 총 개수 + */ async getProblems(options: { userId: number | null cursor: number | null @@ -66,6 +71,11 @@ export class ProblemService { }) } + /** + * 특정 문제를 가져옵니다. + * 문제와 해당 태그를 가져와서 ProblemResponseDto 인스턴스로 변환합니다. + * @returns {ProblemResponseDto} 문제 정보 + */ async getProblem(problemId: number, groupId = OPEN_SPACE_ID) { const data = await this.problemRepository.getProblem(problemId, groupId) const tags = await this.problemRepository.getProblemTags(problemId) @@ -81,6 +91,18 @@ export class ContestProblemService { private readonly contestService: ContestService ) {} + /** + * 주어진 옵션에 따라 대회 문제를 여러개 가져옵니다. + * 이때, 사용자의 제출기록을 확인하여 각 문제의 점수를 계산합니다. + * + * 액세스 정책 + * + * 대회 시작 전: 문제 액세스 불가 (Register 안하면 에러 메시지가 다름) // + * 대회 진행 중: Register한 경우 문제 액세스 가능 // + * 대회 종료 후: 누구나 문제 액세스 가능 + * @see [Contest Problem 정책](https://www.notion.so/skkuding/Contest-Problem-list-ad4f2718af1748bdaff607abb958ba0b?pvs=4) + * @returns {RelatedProblemsResponseDto} data: 대회 문제 목록, total: 대회 문제 총 개수 + */ async getContestProblems( contestId: number, userId: number, @@ -158,6 +180,17 @@ export class ContestProblemService { }) } + /** + * 특정 대회 문제를 가져옵니다. + * + * 액세스 정책 + * + * 대회 시작 전: 문제 액세스 불가 (Register 안하면 에러 메시지가 다름) // + * 대회 진행 중: Register한 경우 문제 액세스 가능 // + * 대회 종료 후: 누구나 문제 액세스 가능 + * @see [Contest Problem 정책](https://www.notion.so/skkuding/Contest-Problem-list-ad4f2718af1748bdaff607abb958ba0b?pvs=4) + * @returns {RelatedProblemResponseDto} problem: 대회 문제 정보, order: 대회 문제 순서 + */ async getContestProblem( contestId: number, problemId: number, @@ -170,11 +203,17 @@ export class ContestProblemService { userId ) const now = new Date() - if (contest.isRegistered && contest.startTime! > now) { - throw new ForbiddenAccessException( - 'Cannot access to problems before the contest starts.' - ) - } else if (!contest.isRegistered && contest.endTime! > now) { + if (contest.isRegistered) { + if (now < contest.startTime!) { + throw new ForbiddenAccessException( + 'Cannot access to Contest problem before the contest starts.' + ) + } else if (now > contest.endTime!) { + throw new ForbiddenAccessException( + 'Cannot access to Contest problem after the contest ends.' + ) + } + } else { throw new ForbiddenAccessException('Register to access this problem.') } diff --git a/apps/backend/apps/client/src/submission/class/create-submission.dto.ts b/apps/backend/apps/client/src/submission/class/create-submission.dto.ts index 59298caff5..c793f5826c 100644 --- a/apps/backend/apps/client/src/submission/class/create-submission.dto.ts +++ b/apps/backend/apps/client/src/submission/class/create-submission.dto.ts @@ -37,3 +37,8 @@ export class CreateSubmissionDto { @IsNotEmpty() language: Language } + +export class CreateUserTestSubmissionDto extends CreateSubmissionDto { + @IsNotEmpty() + userTestcases: { id: number; in: string; out: string }[] +} diff --git a/apps/backend/apps/client/src/submission/class/judge-request.ts b/apps/backend/apps/client/src/submission/class/judge-request.ts index 8df86c319d..00d6ee6f3e 100644 --- a/apps/backend/apps/client/src/submission/class/judge-request.ts +++ b/apps/backend/apps/client/src/submission/class/judge-request.ts @@ -22,3 +22,24 @@ export class JudgeRequest { this.memoryLimit = calculateMemoryLimit(language, problem.memoryLimit) } } + +export class UserTestcaseJudgeRequest extends JudgeRequest { + userTestcases: { + id: number + in: string + out: string + hidden: boolean + }[] + + constructor( + code: Snippet[], + language: Language, + problem: { id: number; timeLimit: number; memoryLimit: number }, + userTestcases: { id: number; in: string; out: string }[] + ) { + super(code, language, problem) + this.userTestcases = userTestcases.map((tc) => { + return { ...tc, hidden: false } + }) + } +} diff --git a/apps/backend/apps/client/src/submission/submission-pub.service.ts b/apps/backend/apps/client/src/submission/submission-pub.service.ts index 9441da64b2..9ed6e3e057 100644 --- a/apps/backend/apps/client/src/submission/submission-pub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-pub.service.ts @@ -6,12 +6,13 @@ import { EXCHANGE, JUDGE_MESSAGE_TYPE, RUN_MESSAGE_TYPE, - SUBMISSION_KEY + SUBMISSION_KEY, + USER_TESTCASE_MESSAGE_TYPE } from '@libs/constants' import { EntityNotExistException } from '@libs/exception' import { PrismaService } from '@libs/prisma' import { Snippet } from './class/create-submission.dto' -import { JudgeRequest } from './class/judge-request' +import { JudgeRequest, UserTestcaseJudgeRequest } from './class/judge-request' @Injectable() export class SubmissionPublicationService { @@ -25,7 +26,9 @@ export class SubmissionPublicationService { async publishJudgeRequestMessage( code: Snippet[], submission: Submission, - isTest = false + isTest = false, // Open Testcase 채점 여부 + isUserTest = false, // User Testcase 채점 여부 + userTestcases?: { id: number; in: string; out: string }[] // User Testcases ) { const problem = await this.prisma.problem.findUnique({ where: { id: submission.problemId }, @@ -40,7 +43,14 @@ export class SubmissionPublicationService { throw new EntityNotExistException('problem') } - const judgeRequest = new JudgeRequest(code, submission.language, problem) + const judgeRequest = isUserTest + ? new UserTestcaseJudgeRequest( + code, + submission.language, + problem, + userTestcases! + ) + : new JudgeRequest(code, submission.language, problem) const span = this.traceService.startSpan( 'publishJudgeRequestMessage.publish' @@ -50,7 +60,11 @@ export class SubmissionPublicationService { await this.amqpConnection.publish(EXCHANGE, SUBMISSION_KEY, judgeRequest, { messageId: String(submission.id), persistent: true, - type: isTest ? RUN_MESSAGE_TYPE : JUDGE_MESSAGE_TYPE + type: isTest + ? RUN_MESSAGE_TYPE + : isUserTest + ? USER_TESTCASE_MESSAGE_TYPE + : JUDGE_MESSAGE_TYPE }) span.end() } diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 214818add5..9f1ff2adf3 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -10,7 +10,12 @@ import type { Cache } from 'cache-manager' import { plainToInstance } from 'class-transformer' import { validateOrReject, ValidationError } from 'class-validator' import { Span } from 'nestjs-otel' -import { testKey } from '@libs/cache' +import { + testKey, + testcasesKey, + userTestKey, + userTestcasesKey +} from '@libs/cache' import { CONSUME_CHANNEL, EXCHANGE, @@ -19,7 +24,8 @@ import { RESULT_QUEUE, RUN_MESSAGE_TYPE, Status, - TEST_SUBMISSION_EXPIRE_TIME + TEST_SUBMISSION_EXPIRE_TIME, + USER_TESTCASE_MESSAGE_TYPE } from '@libs/constants' import { UnprocessableDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' @@ -42,9 +48,16 @@ export class SubmissionSubscriptionService implements OnModuleInit { try { const res = await this.validateJudgerResponse(msg) - if (raw.properties.type === RUN_MESSAGE_TYPE) { + if ( + raw.properties.type === RUN_MESSAGE_TYPE || + raw.properties.type === USER_TESTCASE_MESSAGE_TYPE + ) { const testRequestedUserId = res.submissionId // test용 submissionId == test를 요청한 userId - await this.handleRunMessage(res, testRequestedUserId) + await this.handleRunMessage( + res, + testRequestedUserId, + raw.properties.type === USER_TESTCASE_MESSAGE_TYPE ? true : false + ) return } @@ -75,29 +88,46 @@ export class SubmissionSubscriptionService implements OnModuleInit { ) } - async handleRunMessage(msg: JudgerResponse, userId: number): Promise { - const key = testKey(userId) + async handleRunMessage( + msg: JudgerResponse, + userId: number, + isUserTest = false + ): Promise { const status = Status(msg.resultCode) const testcaseId = msg.judgeResult?.testcaseId const output = this.parseError(msg, status) - - const testcases = - (await this.cacheManager.get< - { - id: number - result: ResultStatus - output?: string - }[] - >(key)) ?? [] - - testcases.forEach((tc) => { - if (!testcaseId || tc.id === testcaseId) { - tc.result = status - tc.output = output + if (!testcaseId) { + const key = isUserTest ? userTestcasesKey(userId) : testcasesKey(userId) + const testcaseIds = (await this.cacheManager.get(key)) ?? [] + + for (const testcaseId of testcaseIds) { + await this.cacheManager.set( + isUserTest + ? userTestKey(userId, testcaseId) + : testKey(userId, testcaseId), + { id: testcaseId, result: status, output }, + TEST_SUBMISSION_EXPIRE_TIME + ) } - }) + return + } + + const key = isUserTest + ? userTestKey(userId, testcaseId) + : testKey(userId, testcaseId) + + const testcase = await this.cacheManager.get<{ + id: number + result: ResultStatus + output?: string + }>(key) + if (testcase) { + testcase.id = testcaseId + testcase.result = status + testcase.output = output + } - await this.cacheManager.set(key, testcases, TEST_SUBMISSION_EXPIRE_TIME) + await this.cacheManager.set(key, testcase, TEST_SUBMISSION_EXPIRE_TIME) } parseError(msg: JudgerResponse, status: ResultStatus): string { diff --git a/apps/backend/apps/client/src/submission/submission.controller.ts b/apps/backend/apps/client/src/submission/submission.controller.ts index 8464edece7..e71b0e74d8 100644 --- a/apps/backend/apps/client/src/submission/submission.controller.ts +++ b/apps/backend/apps/client/src/submission/submission.controller.ts @@ -16,13 +16,22 @@ import { IDValidationPipe, RequiredIntPipe } from '@libs/pipe' -import { CreateSubmissionDto } from './class/create-submission.dto' +import { + CreateSubmissionDto, + type CreateUserTestSubmissionDto +} from './class/create-submission.dto' import { SubmissionService } from './submission.service' @Controller('submission') export class SubmissionController { constructor(private readonly submissionService: SubmissionService) {} + /** + * 아직 채점되지 않은 제출 기록을 만들고, 채점 서버에 채점 요청을 보냅니다. + * 세 가지 제출 유형(일반 문제, 대회 문제, Workbook 문제)에 대해 제출할 수 있습니다. + * createSubmission은 제출 유형에 따라 다른 서비스 메소드를 호출합니다. + * @returns 아직 채점되지 않은 제출 기록 + */ @Post() async createSubmission( @Req() req: AuthenticatedRequest, @@ -88,6 +97,33 @@ export class SubmissionController { return await this.submissionService.getTestResult(req.user.id) } + /** + * 유저가 생성한 테스트케이스에 대해 실행을 요청합니다. + * 채점 결과는 Cache에 저장됩니다. + */ + @Post('user-test') + async submitUserTest( + @Req() req: AuthenticatedRequest, + @Query('problemId', new RequiredIntPipe('problemId')) problemId: number, + @Body() userTestSubmissionDto: CreateUserTestSubmissionDto + ) { + return await this.submissionService.submitTest( + req.user.id, + problemId, + userTestSubmissionDto, + true + ) + } + + /** + * 유저가 생성한 테스트케이스에 대한 실행 결과를 조회합니다. + * @returns Testcase별 결과가 담겨있는 Object + */ + @Get('user-test') + async getUserTestResult(@Req() req: AuthenticatedRequest) { + return await this.submissionService.getTestResult(req.user.id, true) + } + @Get('delay-cause') async checkDelay() { return await this.submissionService.checkDelay() diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index 3670858765..88d23d69dd 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -14,7 +14,12 @@ import { AxiosRequestConfig } from 'axios' import { Cache } from 'cache-manager' import { plainToInstance } from 'class-transformer' import { Span } from 'nestjs-otel' -import { testKey } from '@libs/cache' +import { + testKey, + testcasesKey, + userTestKey, + userTestcasesKey +} from '@libs/cache' import { MIN_DATE, OPEN_SPACE_ID, @@ -30,6 +35,7 @@ import { PrismaService } from '@libs/prisma' import { ProblemRepository } from '@client/problem/problem.repository' import { CreateSubmissionDto, + CreateUserTestSubmissionDto, Snippet, Template } from './class/create-submission.dto' @@ -93,6 +99,8 @@ export class SubmissionService { groupId = OPEN_SPACE_ID ) { const now = new Date() + + // 진행 중인 대회인지 확인합니다. const contest = await this.prisma.contest.findFirst({ where: { id: contestId, @@ -108,6 +116,8 @@ export class SubmissionService { if (!contest) { throw new EntityNotExistException('Contest') } + + // 대회에 등록되어 있는지 확인합니다. const contestRecord = await this.prisma.contestRecord.findUnique({ where: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -215,6 +225,10 @@ export class SubmissionService { return submission } + /** + * 빈 제출 기록을 생성하고 채점 요청을 보냅니다. + * @returns 생성된 제출 기록 + */ @Span() async createSubmission( submissionDto: CreateSubmissionDto, @@ -324,7 +338,8 @@ export class SubmissionService { async submitTest( userId: number, problemId: number, - submissionDto: CreateSubmissionDto + submissionDto: CreateSubmissionDto, + isUserTest = false ) { const problem = await this.prisma.problem.findFirst({ where: { @@ -368,6 +383,32 @@ export class SubmissionService { updateTime: new Date() } + // User Testcase에 대한 TEST 요청인 경우 + if (isUserTest) { + await this.publishUserTestMessage( + userId, + submissionDto.code, + testSubmission, + (submissionDto as CreateUserTestSubmissionDto).userTestcases + ) + return + } + + // Open Testcase에 대한 TEST 요청인 경우 + await this.publishTestMessage( + problemId, + userId, + submissionDto.code, + testSubmission + ) + } + + async publishTestMessage( + problemId: number, + userId: number, + code: Snippet[], + testSubmission: Submission + ) { const rawTestcases = await this.prisma.problemTestcase.findMany({ where: { problemId, @@ -375,40 +416,69 @@ export class SubmissionService { } }) - const testcases: { - id: number - result: ResultStatus - }[] = [] - - for (const testcase of rawTestcases) { - testcases.push({ - id: testcase.id, - result: 'Judging' - }) + const testcaseIds: number[] = [] + for (const rawTestcase of rawTestcases) { + await this.cacheManager.set( + testKey(userId, rawTestcase.id), + { id: rawTestcase.id, result: 'Judging' }, + TEST_SUBMISSION_EXPIRE_TIME + ) + testcaseIds.push(rawTestcase.id) } + await this.cacheManager.set(testcasesKey(userId), testcaseIds) - await this.cacheManager.set( - testKey(userId), - testcases, - TEST_SUBMISSION_EXPIRE_TIME - ) + await this.publish.publishJudgeRequestMessage(code, testSubmission, true) + } + + async publishUserTestMessage( + userId: number, + code: Snippet[], + testSubmission: Submission, + userTestcases: { id: number; in: string; out: string }[] + ) { + const testcaseIds: number[] = [] + for (const testcase of userTestcases) { + await this.cacheManager.set( + userTestKey(userId, testcase.id), + { id: testcase.id, result: 'Judging' }, + TEST_SUBMISSION_EXPIRE_TIME + ) + testcaseIds.push(testcase.id) + } + await this.cacheManager.set(userTestcasesKey(userId), testcaseIds) await this.publish.publishJudgeRequestMessage( - submissionDto.code, + code, testSubmission, - true + false, + true, + userTestcases ) } - async getTestResult(userId: number) { - const key = testKey(userId) - return await this.cacheManager.get< - { + async getTestResult(userId: number, isUserTest = false) { + const testCasesKey = isUserTest + ? userTestcasesKey(userId) + : testcasesKey(userId) + + const testcases = + (await this.cacheManager.get(testCasesKey)) ?? [] + + const results: { id: number; result: ResultStatus; output?: string }[] = [] + for (const testcaseId of testcases) { + const key = isUserTest + ? userTestKey(userId, testcaseId) + : testKey(userId, testcaseId) + const testcase = await this.cacheManager.get<{ id: number result: ResultStatus output?: string - }[] - >(key) + }>(key) + if (testcase) { + results.push(testcase) + } + } + return results } @Span() diff --git a/apps/backend/libs/cache/src/keys.ts b/apps/backend/libs/cache/src/keys.ts index 16c0416b54..1152737481 100644 --- a/apps/backend/libs/cache/src/keys.ts +++ b/apps/backend/libs/cache/src/keys.ts @@ -9,4 +9,12 @@ export const joinGroupCacheKey = (groupId: number) => `group:${groupId}` export const invitationCodeKey = (code: string) => `invite:${code}` export const invitationGroupKey = (groupId: number) => `invite:to:${groupId}` -export const testKey = (userId: number) => `test:user:${userId}` +/* TEST API용 Key */ +export const testKey = (userId: number, testcaseId: number) => + `test:user:${userId}:testcase:${testcaseId}` +export const testcasesKey = (userId: number) => `test:user:${userId}` + +/* User Test API용 Key */ +export const userTestKey = (userId: number, testcaseId: number) => + `user-test:${userId}:testcase:${testcaseId}` +export const userTestcasesKey = (userId: number) => `user-test:${userId}` diff --git a/apps/backend/libs/constants/src/rabbitmq.constants.ts b/apps/backend/libs/constants/src/rabbitmq.constants.ts index 77a215e1da..671f68d23f 100644 --- a/apps/backend/libs/constants/src/rabbitmq.constants.ts +++ b/apps/backend/libs/constants/src/rabbitmq.constants.ts @@ -12,3 +12,4 @@ export const ORIGIN_HANDLER_NAME = 'codedang-handler' export const JUDGE_MESSAGE_TYPE = 'judge' export const RUN_MESSAGE_TYPE = 'run' +export const USER_TESTCASE_MESSAGE_TYPE = 'userTestCase' diff --git a/apps/backend/package.json b/apps/backend/package.json index b72b3a3a16..1c2a3d22a2 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,35 +19,35 @@ }, "dependencies": { "@apollo/server": "^4.11.2", - "@aws-sdk/client-s3": "^3.685.0", - "@aws-sdk/client-ses": "^3.682.0", - "@aws-sdk/credential-provider-node": "^3.682.0", - "@golevelup/nestjs-rabbitmq": "^5.5.0", + "@aws-sdk/client-s3": "^3.689.0", + "@aws-sdk/client-ses": "^3.687.0", + "@aws-sdk/credential-provider-node": "^3.687.0", + "@golevelup/nestjs-rabbitmq": "^5.6.1", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/apollo": "^12.2.1", - "@nestjs/axios": "^3.1.1", + "@nestjs/axios": "^3.1.2", "@nestjs/cache-manager": "^2.3.0", - "@nestjs/common": "^10.4.6", + "@nestjs/common": "^10.4.7", "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.6", + "@nestjs/core": "^10.4.7", "@nestjs/graphql": "^12.2.1", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", - "@nestjs/platform-express": "^10.4.6", + "@nestjs/platform-express": "^10.4.7", "@nestjs/swagger": "^7.4.2", "@opentelemetry/api": "~1.9.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.54.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.54.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.54.2", + "@opentelemetry/exporter-trace-otlp-http": "^0.54.2", "@opentelemetry/host-metrics": "^0.35.4", "@opentelemetry/instrumentation-express": "^0.44.0", - "@opentelemetry/instrumentation-http": "^0.54.0", + "@opentelemetry/instrumentation-http": "^0.54.2", "@opentelemetry/resources": "^1.27.0", "@opentelemetry/sdk-metrics": "^1.27.0", - "@opentelemetry/sdk-node": "^0.54.0", + "@opentelemetry/sdk-node": "^0.54.2", "@opentelemetry/sdk-trace-node": "^1.27.0", "@opentelemetry/semantic-conventions": "^1.27.0", - "@prisma/client": "^5.21.1", - "@prisma/instrumentation": "~5.21.1", + "@prisma/client": "^5.22.0", + "@prisma/instrumentation": "~5.22.0", "argon2": "^0.41.1", "axios": "^1.7.7", "cache-manager": "^5.7.6", @@ -77,7 +77,7 @@ "pino-pretty": "^11.3.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", - "sql-formatter": "^15.4.5", + "sql-formatter": "^15.4.6", "zod": "^3.23.8" }, "devDependencies": { @@ -85,10 +85,10 @@ "@istanbuljs/nyc-config-typescript": "^1.0.2", "@nestjs/cli": "^10.4.7", "@nestjs/schematics": "^10.2.3", - "@nestjs/testing": "^10.4.6", + "@nestjs/testing": "^10.4.7", "@swc-node/register": "^1.10.9", "@swc/cli": "^0.5.0", - "@swc/core": "^1.8.0", + "@swc/core": "^1.9.2", "@types/cache-manager": "^4.0.6", "@types/chai": "^4.3.20", "@types/chai-as-promised": "^7.1.8", @@ -104,7 +104,7 @@ "chai-as-promised": "^7.1.2", "mocha": "^10.8.2", "nyc": "^17.1.0", - "prisma": "^5.21.1", + "prisma": "^5.22.0", "prisma-nestjs-graphql": "^20.0.3", "proxyquire": "^2.1.3", "sinon": "^19.0.2", diff --git a/apps/frontend/.eslintrc.js b/apps/frontend/.eslintrc.js index ca3136496b..e8593ab767 100644 --- a/apps/frontend/.eslintrc.js +++ b/apps/frontend/.eslintrc.js @@ -14,7 +14,7 @@ module.exports = { overrides: [ { files: ['*.tsx'], - excludedFiles: ['components/ui/*.tsx'], + excludedFiles: ['components/shadcn/*.tsx'], rules: { 'react/function-component-definition': [ 'error', diff --git a/apps/frontend/__tests__/components.test.tsx b/apps/frontend/__tests__/components.test.tsx deleted file mode 100644 index e0a9e4b157..0000000000 --- a/apps/frontend/__tests__/components.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import ContestCard from '@/app/(main)/_components/ContestCard' -import { render, screen } from '@testing-library/react' -import { expect, test } from 'vitest' - -test('ContestCard', () => { - render( - - ) - expect(screen.getByText('test')).toBeDefined() - expect(screen.getByText('ongoing')).toBeDefined() -}) diff --git a/apps/frontend/__tests__/utils.test.ts b/apps/frontend/__tests__/utils.test.ts deleted file mode 100644 index 61029133ed..0000000000 --- a/apps/frontend/__tests__/utils.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { convertToLetter, dateFormatter } from '@/lib/utils' -import { test, expect } from 'vitest' - -test('convertToLetter', () => { - expect(convertToLetter(0)).toBe('A') - expect(convertToLetter(1)).toBe('B') - expect(convertToLetter(2)).toBe('C') - expect(convertToLetter(25)).toBe('Z') -}) - -test('dateFormatter', () => { - expect(dateFormatter('2022-01-01', 'YYYY-MM-DD')).toBe('2022-01-01') - expect(dateFormatter('2022-01-01', 'DD/MM/YYYY')).toBe('01/01/2022') - expect(dateFormatter('2022-01-01', 'DD MMM YYYY')).toBe('01 Jan 2022') -}) diff --git a/apps/frontend/app/(client)/(code-editor)/_components/CopyButton.tsx b/apps/frontend/app/(client)/(code-editor)/_components/CopyButton.tsx new file mode 100644 index 0000000000..8b5e4c7aab --- /dev/null +++ b/apps/frontend/app/(client)/(code-editor)/_components/CopyButton.tsx @@ -0,0 +1,170 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@/components/shadcn/tooltip' +import { cn } from '@/libs/utils' +import copyBlueIcon from '@/public/icons/copy-blue.svg' +import { LazyMotion, m, domAnimation } from 'framer-motion' +import Image from 'next/image' +import { + useEffect, + useRef, + useState, + type ComponentPropsWithoutRef +} from 'react' +import { useCopyToClipboard } from 'react-use' +import { toast } from 'sonner' + +const useCopy = () => { + const [, copyToClipboard] = useCopyToClipboard() + + const [copied, setCopied] = useState(false) + const timeoutIDRef = useRef(null) + + const copy = (value: string) => { + copyToClipboard(value) + setCopied(true) + + if (timeoutIDRef.current) { + clearTimeout(timeoutIDRef.current) + } + timeoutIDRef.current = setTimeout(() => { + setCopied(false) + timeoutIDRef.current = null + }, 2000) + } + + return { copied, copy } +} + +interface CopyButtonProps extends ComponentPropsWithoutRef<'button'> { + iconSize?: number + value: string + withTooltip?: boolean +} + +export default function CopyButton({ + value, + iconSize = 24, + withTooltip = true, + onClick, + className, + ...props +}: CopyButtonProps) { + const { copied, copy } = useCopy() + + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + return ( + + + {copied ? ( + + ) : ( + + + { + onClick?.(e) + copy(value) + toast('Successfully copied', { + unstyled: true, + closeButton: false, + icon: copy, + style: { backgroundColor: '#f0f8ff' }, + classNames: { + toast: 'inline-flex items-center py-2 px-3 rounded gap-2', + title: 'text-primary font-medium' + } + }) + }} + className="transition-opacity hover:opacity-60" + {...props} + > + + + {withTooltip ? ( + +

Copy

+
+ ) : null} +
+
+ )} +
+
+ ) +} + +function CopyIcon(props: ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + ) +} + +function CopyCompleteIcon(props: ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + + ) +} diff --git a/apps/frontend/components/EditorDescription.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorDescription.tsx similarity index 61% rename from apps/frontend/components/EditorDescription.tsx rename to apps/frontend/app/(client)/(code-editor)/_components/EditorDescription.tsx index 79ea1ecd94..6810ab81b7 100644 --- a/apps/frontend/components/EditorDescription.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorDescription.tsx @@ -6,57 +6,24 @@ import { AccordionContent, AccordionItem, AccordionTrigger -} from '@/components/ui/accordion' -import { Badge } from '@/components/ui/badge' +} from '@/components/shadcn/accordion' +import { Badge } from '@/components/shadcn/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger -} from '@/components/ui/dialog' -import { convertToLetter } from '@/lib/utils' -import CopyIcon from '@/public/24_copy.svg' -import compileIcon from '@/public/compileVersion.svg' -import copyIcon from '@/public/copy.svg' -import copyCompleteIcon from '@/public/copyComplete.svg' +} from '@/components/shadcn/dialog' +import { convertToLetter } from '@/libs/utils' +import compileIcon from '@/public/icons/compile-version.svg' import type { ContestProblem, ProblemDetail } from '@/types/type' import type { Level } from '@/types/type' -import { motion } from 'framer-motion' import { sanitize } from 'isomorphic-dompurify' import { FileText } from 'lucide-react' import Image from 'next/image' -import { useState } from 'react' -import useCopyToClipboard from 'react-use/lib/useCopyToClipboard' -import { toast } from 'sonner' +import CopyButton from './CopyButton' import { WhitespaceVisualizer } from './WhitespaceVisualizer' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from './ui/tooltip' - -const useCopy = () => { - const [, copyToClipboard] = useCopyToClipboard() - - // copiedID is used to show the checkmark icon when the user copies the input/output - const [copiedID, setCopiedID] = useState('') - const [timeoutID, setTimeoutID] = useState(null) - - const copy = (value: string, id: string) => { - copyToClipboard(value) - setCopiedID(id) - - // Clear the timeout if it's already set - // This will prevent the previous setTimeout from executing - timeoutID && clearTimeout(timeoutID) - const timeout = setTimeout(() => setCopiedID(''), 2000) - setTimeoutID(timeout) - } - - return { copiedID, copy } -} export function EditorDescription({ problem, @@ -67,8 +34,6 @@ export function EditorDescription({ contestProblems?: ContestProblem[] isContest?: boolean }) { - const { copiedID, copy } = useCopy() - const level = problem.difficulty const levelNumber = level.slice(-1) return ( @@ -120,53 +85,7 @@ export function EditorDescription({

Input {index + 1}

- - - - {copiedID == `input-${id}` ? ( - copy - ) : ( - - { - copy(input + '\n\n', `input-${id}`) // add newline to the end for easy testing - toast('Successfully copied', { - unstyled: true, - closeButton: false, - icon: copy, - style: { backgroundColor: '#f0f8ff' }, - classNames: { - toast: - 'inline-flex items-center py-2 px-3 rounded gap-2', - title: 'text-primary font-medium' - } - }) - }} - className="cursor-pointer transition-opacity hover:opacity-60" - src={copyIcon} - alt="copy" - width={24} - /> - - )} - - -

Copy

-
-
-
+
@@ -178,53 +97,7 @@ export function EditorDescription({

Output {index + 1}

- - - - {copiedID == `output-${id}` ? ( - copy - ) : ( - - { - copy(output + '\n\n', `output-${id}`) // add newline to the end for easy testing - toast('Successfully copied', { - unstyled: true, - closeButton: false, - icon: copy, - style: { backgroundColor: '#f0f8ff' }, - classNames: { - toast: - 'inline-flex items-center py-2 px-3 rounded gap-2', - title: 'text-primary font-medium' - } - }) - }} - className="cursor-pointer transition-opacity hover:opacity-60" - src={copyIcon} - alt="copy" - width={24} - /> - - )} - - -

Copy

-
-
-
+
diff --git a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/BackCautionDialog.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/BackCautionDialog.tsx new file mode 100644 index 0000000000..d27c9b2ff7 --- /dev/null +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/BackCautionDialog.tsx @@ -0,0 +1,57 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/shadcn/alert-dialog' +import type { MutableRefObject } from 'react' + +interface BackCautionDialogProps { + confrim: MutableRefObject + isOpen: boolean + title: string + description: string + onClose: () => void + onBack: () => void +} + +export function BackCautionDialog({ + confrim, + isOpen, + title, + description, + onClose, + onBack +}: BackCautionDialogProps) { + return ( + + + + {title} + + {description} + + + + window.history.pushState(null, '', '')} + className="border border-neutral-300 bg-white text-neutral-400 hover:bg-neutral-200" + > + Cancel + + { + confrim.current = true + onBack() + }} + > + OK + + + + + ) +} diff --git a/apps/frontend/components/EditorHeader.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx similarity index 60% rename from apps/frontend/components/EditorHeader.tsx rename to apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx index 6a80c7a8fb..c16cafbf03 100644 --- a/apps/frontend/components/EditorHeader.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx @@ -10,8 +10,8 @@ import { AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger -} from '@/components/ui/alert-dialog' -import { Button } from '@/components/ui/button' +} from '@/components/shadcn/alert-dialog' +import { Button } from '@/components/shadcn/button' import { Select, SelectContent, @@ -19,59 +19,69 @@ import { SelectItem, SelectTrigger, SelectValue -} from '@/components/ui/select' -import { auth } from '@/lib/auth' -import { fetcherWithAuth } from '@/lib/utils' -import submitIcon from '@/public/submit.svg' +} from '@/components/shadcn/select' +import { auth } from '@/libs/auth' +import { fetcherWithAuth } from '@/libs/utils' +import submitIcon from '@/public/icons/submit.svg' import useAuthModalStore from '@/stores/authModal' import { useLanguageStore, - createCodeStore, - getKey, - setItem, - getItem + useCodeStore, + getStorageKey, + getCodeFromLocalStorage } from '@/stores/editor' import type { Language, ProblemDetail, Submission, - Template, - TestResult + Template } from '@/types/type' import JSConfetti from 'js-confetti' import { Save } from 'lucide-react' import type { Route } from 'next' import Image from 'next/image' -import { useRouter } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import { useEffect, useRef, useState } from 'react' import { BsTrash3 } from 'react-icons/bs' -import { IoPlayCircleOutline } from 'react-icons/io5' import { useInterval } from 'react-use' import { toast } from 'sonner' +import { useTestPollingStore } from '../context/TestPollingStoreProvider' +import { BackCautionDialog } from './BackCautionDialog' +import RunTestButton from './RunTestButton' interface ProblemEditorProps { problem: ProblemDetail contestId?: number templateString: string - setTestResults: (testResults: TestResult[]) => void } export default function Editor({ problem, contestId, - templateString, - setTestResults + templateString }: ProblemEditorProps) { const { language, setLanguage } = useLanguageStore(problem.id, contestId)() - const { code, setCode } = createCodeStore((state) => state) - const [loading, setLoading] = useState(false) + const setCode = useCodeStore((state) => state.setCode) + const getCode = useCodeStore((state) => state.getCode) + + const isTesting = useTestPollingStore((state) => state.isTesting) + const [isSubmitting, setIsSubmitting] = useState(false) + const loading = isTesting || isSubmitting + const [submissionId, setSubmissionId] = useState(null) - const [templateCode, setTemplateCode] = useState(null) + const [templateCode, setTemplateCode] = useState('') const [userName, setUserName] = useState('') const router = useRouter() + const pathname = usePathname() const confetti = typeof window !== 'undefined' ? new JSConfetti() : null - const storageKey = useRef(getKey(language, problem.id, userName, contestId)) + const storageKey = useRef( + getStorageKey(language, problem.id, userName, contestId) + ) const { currentModal, showSignIn } = useAuthModalStore((state) => state) + const [showModal, setShowModal] = useState(false) + const pushed = useRef(false) + const whereToPush = useRef('') + const isModalConfrimed = useRef(false) useInterval( async () => { @@ -84,17 +94,18 @@ export default function Editor({ if (res.ok) { const submission: Submission = await res.json() if (submission.result !== 'Judging') { - setLoading(false) + setIsSubmitting(false) const href = contestId ? `/contest/${contestId}/problem/${problem.id}/submission/${submissionId}` : `/problem/${problem.id}/submission/${submissionId}` - router.push(href as Route) + router.replace(href as Route) + window.history.pushState(null, '', '') if (submission.result === 'Accepted') { confetti?.addConfetti() } } } else { - setLoading(false) + setIsSubmitting(false) toast.error('Please try again later.') } }, @@ -122,17 +133,35 @@ export default function Editor({ }, [language]) useEffect(() => { - storageKey.current = getKey(language, problem.id, userName, contestId) - getLocalstorageCode() + storageKey.current = getStorageKey( + language, + problem.id, + userName, + contestId + ) + if (storageKey.current !== undefined) { + const storedCode = getCodeFromLocalStorage(storageKey.current) + setCode(storedCode || templateCode) + } }, [userName, problem, contestId, language, templateCode]) + const storeCodeToLocalStorage = (code: string) => { + if (storageKey.current !== undefined) { + localStorage.setItem(storageKey.current, code) + } else { + toast.error('Failed to save the code') + } + } const submit = async () => { + const code = getCode() + if (code === '') { toast.error('Please write code before submission') return } + setSubmissionId(null) - setLoading(true) + setIsSubmitting(true) const res = await fetcherWithAuth.post('submission', { json: { language, @@ -153,11 +182,12 @@ export default function Editor({ } }) if (res.ok) { - saveCode(true) + toast.success('Successfully submitted the code') + storeCodeToLocalStorage(code) const submission: Submission = await res.json() setSubmissionId(submission.id) } else { - setLoading(false) + setIsSubmitting(false) if (res.status === 401) { showSignIn() toast.error('Log in first to submit your code') @@ -165,119 +195,95 @@ export default function Editor({ } } - const submitTest = async () => { - if (code === '') { - toast.error('Please write code before test') - return - } - setLoading(true) - const res = await fetcherWithAuth.post('submission/test', { - json: { - language, - code: [ - { - id: 1, - text: code, - locked: false - } - ] - }, - searchParams: { - problemId: problem.id - }, - next: { - revalidate: 0 - } - }) - if (res.ok) { - saveCode() - pollTestResult() - } else { - setLoading(false) - if (res.status === 401) { - showSignIn() - toast.error('Log in first to test your code') - } else toast.error('Please try again later.') - } - } - - const pollTestResult = async () => { - let attempts = 0 - const maxAttempts = 10 - const pollingInterval = 2000 - - const poll = async () => { - const res = await fetcherWithAuth.get('submission/test', { - next: { - revalidate: 0 - } - }) - - if (res.ok) { - const resultArray: TestResult[] = await res.json() - - setTestResults(resultArray) - - const allJudged = resultArray.every( - (submission: TestResult) => submission.result !== 'Judging' - ) - - if (!allJudged) { - if (attempts < maxAttempts) { - attempts += 1 - setTimeout(poll, pollingInterval) - } else { - setLoading(false) - toast.error('Judging took too long. Please try again later.') - } - } else { - setLoading(false) - } - } else { - setLoading(false) - toast.error('Please try again later.') - } - } - - poll() - } - - const saveCode = async (isSubmitting?: boolean) => { + const saveCode = async () => { const session = await auth() + const code = getCode() + if (!session) { toast.error('Log in first to save your code') + } else if (storageKey.current !== undefined) { + localStorage.setItem(storageKey.current, code) + toast.success('Successfully saved the code') } else { - if (storeCodeToLocalstorage()) { - toast.success( - `Successfully ${isSubmitting ? 'submitted' : 'saved'} the code` - ) - } else toast.error('Failed to save the code') + toast.error('Failed to save the code') } } - const storeCodeToLocalstorage = () => { + const resetCode = () => { if (storageKey.current !== undefined) { - setItem(storageKey.current, code) - return true - } - return false + localStorage.setItem(storageKey.current, templateCode) + setCode(templateCode) + toast.success('Successfully reset the code') + } else toast.error('Failed to reset the code') } - const getLocalstorageCode = () => { + const checkSaved = () => { + const code = getCode() if (storageKey.current !== undefined) { - const storedCode = getItem(storageKey.current) ?? '' - setCode(storedCode ? JSON.parse(storedCode) : templateCode) + const storedCode = getCodeFromLocalStorage(storageKey.current) + if (storedCode && storedCode === code) return true + else if (!storedCode && templateCode === code) return true + else return false } + return true } - const resetCode = () => { - if (storageKey.current !== undefined) { - setItem(storageKey.current, templateCode ?? '') - setCode(templateCode ?? '') - toast.success('Successfully reset the code') - } else toast.error('Failed to reset the code') + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (!checkSaved()) { + e.preventDefault() + whereToPush.current = pathname + } } + useEffect(() => { + storageKey.current = getStorageKey( + language, + problem.id, + userName, + contestId + ) + + const handlePopState = () => { + if (!checkSaved()) { + whereToPush.current = contestId ? `/contest/${contestId}` : '/problem' + setShowModal(true) + } else window.history.back() + } + if (!pushed.current) { + window.history.pushState(null, '', '') + pushed.current = true + } + window.addEventListener('beforeunload', handleBeforeUnload) + window.addEventListener('popstate', handlePopState) + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + window.removeEventListener('popstate', handlePopState) + } + }, []) + + useEffect(() => { + const originalPush = router.push + + router.push = (href, ...args) => { + if (checkSaved() || isModalConfrimed.current) { + originalPush(href, ...args) + return + } + isModalConfrimed.current = false + const isConfirmed = window.confirm( + 'Are you sure you want to leave this page? Changes you made may not be saved.' + ) + if (isConfirmed) { + originalPush(href, ...args) + } + } + + return () => { + router.push = originalPush + } + }, [router]) + return (
@@ -315,21 +321,18 @@ export default function Editor({
- + saveCode={storeCodeToLocalStorage} + />
+ setShowModal(false)} + onBack={() => router.push(whereToPush.current as Route)} + />
) } diff --git a/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/RunTestButton.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/RunTestButton.tsx new file mode 100644 index 0000000000..a5c3cf61f8 --- /dev/null +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/RunTestButton.tsx @@ -0,0 +1,120 @@ +import { Button, type ButtonProps } from '@/components/shadcn/button' +import { isHttpError, safeFetcherWithAuth } from '@/libs/utils' +import useAuthModalStore from '@/stores/authModal' +import { useCodeStore } from '@/stores/editor' +import type { TestcaseItem } from '@/types/type' +import { useMutation } from '@tanstack/react-query' +import { IoPlayCircleOutline } from 'react-icons/io5' +import { toast } from 'sonner' +import { useTestPollingStore } from '../context/TestPollingStoreProvider' +import { useTestcaseStore } from '../context/TestcaseStoreProvider' + +interface RunTestButtonProps extends ButtonProps { + problemId: number + language: string + saveCode: (code: string) => void +} + +export default function RunTestButton({ + problemId, + language, + saveCode, + ...props +}: RunTestButtonProps) { + const setIsTesting = useTestPollingStore((state) => state.setIsTesting) + const startPolling = useTestPollingStore((state) => state.startPolling) + const showSignIn = useAuthModalStore((state) => state.showSignIn) + const getCode = useCodeStore((state) => state.getCode) + const getUserTestcases = useTestcaseStore((state) => state.getUserTestcases) + + const { mutate } = useMutation({ + mutationFn: ({ + code, + testcases + }: { + code: string + testcases: TestcaseItem[] + }) => + Promise.all([ + safeFetcherWithAuth.post('submission/test', { + json: { + language, + code: [ + { + id: 1, + text: code, + locked: false + } + ] + }, + searchParams: { + problemId + }, + next: { + revalidate: 0 + } + }), + safeFetcherWithAuth.post('submission/user-test', { + json: { + language, + code: [ + { + id: 1, + text: code, + locked: false + } + ], + userTestcases: testcases.map((testcase) => ({ + id: testcase.id, + in: testcase.input, + out: testcase.output + })) + }, + searchParams: { + problemId + }, + next: { + revalidate: 0 + } + }) + ]), + onSuccess: (_, { code }) => { + saveCode(code) + startPolling() + }, + onError: (error) => { + setIsTesting(false) + if (isHttpError(error) && error.response.status === 401) { + showSignIn() + toast.error('Log in first to test your code') + } else { + toast.error('Please try again later.') + } + } + }) + + const submitTest = async () => { + const code = getCode() + const testcases = getUserTestcases() + + if (code === '') { + toast.error('Please write code before test') + return + } + + setIsTesting(true) + mutate({ code, testcases }) + } + + return ( + + ) +} diff --git a/apps/frontend/components/EditorLayout.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorLayout.tsx similarity index 71% rename from apps/frontend/components/EditorLayout.tsx rename to apps/frontend/app/(client)/(code-editor)/_components/EditorLayout.tsx index 571142066f..6c81a48496 100644 --- a/apps/frontend/components/EditorLayout.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorLayout.tsx @@ -1,20 +1,22 @@ +import ContestStatusTimeDiff from '@/components/ContestStatusTimeDiff' import HeaderAuthPanel from '@/components/auth/HeaderAuthPanel' -import { auth } from '@/lib/auth' -import { convertToLetter, fetcher, fetcherWithAuth } from '@/lib/utils' -import codedangLogo from '@/public/codedang-editor.svg' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/shadcn/dropdown-menu' +import { auth } from '@/libs/auth' +import { cn, convertToLetter, fetcher, fetcherWithAuth } from '@/libs/utils' +import checkIcon from '@/public/icons/check-green.svg' +import codedangLogo from '@/public/logos/codedang-editor.svg' import type { Contest, ContestProblem, ProblemDetail } from '@/types/type' import type { Route } from 'next' import Image from 'next/image' import Link from 'next/link' +import { redirect } from 'next/navigation' import { FaSortDown } from 'react-icons/fa' -import ContestStatusTimeDiff from './ContestStatusTimeDiff' import EditorMainResizablePanel from './EditorResizablePanel' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from './ui/dropdown-menu' interface EditorLayoutProps { contestId?: number @@ -41,9 +43,14 @@ export default async function EditorLayout({ problems = await fetcherWithAuth .get(`contest/${contestId}/problem?take=20`) .json() - const ContestProblem: { problem: ProblemDetail } = await fetcherWithAuth - .get(`contest/${contestId}/problem/${problemId}`) - .json() + const res = await fetcherWithAuth( + `contest/${contestId}/problem/${problemId}` + ) + + if (!res.ok && res.status === 403) { + redirect(`/contest/${contestId}/finished/problem/${problemId}`) + } + const ContestProblem: { problem: ProblemDetail } = await res.json() problem = ContestProblem.problem contest = await fetcher(`contest/${contestId}`).json() contest ? (contest.status = 'ongoing') : null // TODO: refactor this after change status interactively @@ -82,8 +89,24 @@ export default async function EditorLayout({ key={p.id} href={`/contest/${contestId}/problem/${p.id}` as Route} > - + {`${convertToLetter(p.order)}. ${p.title}`} + {p.submissionTime && ( +
+ check +
+ )}
))} diff --git a/apps/frontend/components/EditorResizablePanel.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorResizablePanel.tsx similarity index 62% rename from apps/frontend/components/EditorResizablePanel.tsx rename to apps/frontend/app/(client)/(code-editor)/_components/EditorResizablePanel.tsx index 0eda572e61..0620a629b1 100644 --- a/apps/frontend/components/EditorResizablePanel.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorResizablePanel.tsx @@ -5,18 +5,20 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup -} from '@/components/ui/resizable' -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { useLanguageStore, createCodeStore } from '@/stores/editor' -import type { Language, ProblemDetail, TestResult } from '@/types/type' +} from '@/components/shadcn/resizable' +import { ScrollArea, ScrollBar } from '@/components/shadcn/scroll-area' +import { Tabs, TabsList, TabsTrigger } from '@/components/shadcn/tabs' +import { useLanguageStore, useCodeStore } from '@/stores/editor' +import type { Language, ProblemDetail } from '@/types/type' import type { Route } from 'next' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Suspense, useEffect, useState } from 'react' -import Loading from '../app/problem/[problemId]/loading' -import EditorHeader from './EditorHeader' -import TestcasePanel from './TestcasePanel' +import { Suspense, useEffect } from 'react' +import Loading from '../problem/[problemId]/loading' +import EditorHeader from './EditorHeader/EditorHeader' +import TestcasePanel from './TestcasePanel/TestcasePanel' +import { TestPollingStoreProvider } from './context/TestPollingStoreProvider' +import { TestcaseStoreProvider } from './context/TestcaseStoreProvider' interface ProblemEditorProps { problem: ProblemDetail @@ -34,18 +36,7 @@ export default function EditorMainResizablePanel({ const pathname = usePathname() const base = contestId ? `/contest/${contestId}` : '' const { language, setLanguage } = useLanguageStore(problem.id, contestId)() - const [testResults, setTestResults] = useState([]) - const testcases = problem.problemTestcase - const testResultData = - testResults.length > 0 - ? testcases.map((testcase, index) => ({ - id: testcase.id, - input: testcase.input, - expectedOutput: testcase.output, - output: testResults[index]?.output, - result: testResults[index]?.result - })) - : null + useEffect(() => { if (!problem.languages.includes(language)) { setLanguage(problem.languages[0]) @@ -72,7 +63,7 @@ export default function EditorMainResizablePanel({ } > - +
- - - - - - - - - - - {testResultData && ( - <> + + + + + + + + + + + - + - - )} - + + +
@@ -155,7 +148,7 @@ function CodeEditorInEditorResizablePanel({ enableCopyPaste }: CodeEditorInEditorResizablePanelProps) { const { language } = useLanguageStore(problemId, contestId)() - const { code, setCode } = createCodeStore() + const { code, setCode } = useCodeStore() return ( state.sampleTestcases) + + const { + formState, + testcases, + onSubmit, + register, + addTestcase, + removeTestcase + } = useUserTestcasesForm({ + onSubmit: () => setOpen(false) + }) + + return ( + + + + Add Testcase + + + + + + Close + + + +
+ + + Add User Testcase + + + +
+ {sampleTestcases.map((testcase, index) => ( +
+

+ Sample #{(index + 1).toString().padStart(2, '0')} +

+ +
+ ))} + + {testcases.map((testcase, index) => ( +
+

+ User Testcase #{(index + 1).toString().padStart(2, '0')} +

+
+