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/.gitignore b/.gitignore index 1809f60f8c..9b930711ac 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,4 @@ coverage *.pkg - +.idea diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index ca770c4a40..21f8572e44 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -5,7 +5,7 @@ ARG target=client ARG app_env=production -FROM node:20.17.0-alpine AS builder +FROM node:20.18.0-alpine AS builder ARG target COPY . /build @@ -19,7 +19,7 @@ RUN npx prisma generate RUN npm run build ${target} ### PRODUCTION ### -FROM node:20.17.0-alpine +FROM node:20.18.0-alpine ARG target ARG app_env diff --git a/apps/backend/apps/admin/src/contest/contest.resolver.ts b/apps/backend/apps/admin/src/contest/contest.resolver.ts index c749795e55..489a859d96 100644 --- a/apps/backend/apps/admin/src/contest/contest.resolver.ts +++ b/apps/backend/apps/admin/src/contest/contest.resolver.ts @@ -1,19 +1,8 @@ -import { - InternalServerErrorException, - Logger, - NotFoundException, - ParseBoolPipe -} from '@nestjs/common' +import { ParseBoolPipe } from '@nestjs/common' import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql' import { Contest, ContestProblem } from '@generated' -import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' import { AuthenticatedRequest, UseRolesGuard } from '@libs/auth' import { OPEN_SPACE_ID } from '@libs/constants' -import { - ConflictFoundException, - EntityNotExistException, - UnprocessableDataException -} from '@libs/exception' import { CursorValidationPipe, GroupIDPipe, @@ -25,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' @@ -34,7 +23,6 @@ import { UserContestScoreSummaryWithUserInfo } from './model/score-summary' @Resolver(() => Contest) export class ContestResolver { - private readonly logger = new Logger(ContestResolver.name) constructor(private readonly contestService: ContestService) {} @Query(() => [ContestWithParticipants]) @@ -62,16 +50,7 @@ export class ContestResolver { @Args('contestId', { type: () => Int }, new RequiredIntPipe('contestId')) contestId: number ) { - try { - return await this.contestService.getContest(contestId) - } catch (error) { - if ( - error instanceof PrismaClientKnownRequestError && - error.code == 'P2025' - ) { - throw new NotFoundException(error.message) - } - } + return await this.contestService.getContest(contestId) } @Mutation(() => Contest) @@ -85,22 +64,7 @@ export class ContestResolver { groupId: number, @Context('req') req: AuthenticatedRequest ) { - try { - return await this.contestService.createContest( - groupId, - req.user.id, - input - ) - } catch (error) { - if ( - error instanceof UnprocessableDataException || - error instanceof EntityNotExistException - ) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.createContest(groupId, req.user.id, input) } @Mutation(() => Contest) @@ -108,18 +72,7 @@ export class ContestResolver { @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, @Args('input') input: UpdateContestInput ) { - try { - return await this.contestService.updateContest(groupId, input) - } catch (error) { - if ( - error instanceof EntityNotExistException || - error instanceof UnprocessableDataException - ) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.updateContest(groupId, input) } @Mutation(() => Contest) @@ -127,63 +80,52 @@ export class ContestResolver { @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, @Args('contestId', { type: () => Int }) contestId: number ) { - try { - return await this.contestService.deleteContest(groupId, contestId) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.deleteContest(groupId, contestId) } + /** + * Contest의 소속 Group을 Open Space(groupId === 1)로 이동시키기 위한 요청(Publicizing Requests)들을 불러옵니다. + * @returns Publicizing Request 배열 + */ @Query(() => [PublicizingRequest]) @UseRolesGuard() async getPublicizingRequests() { return await this.contestService.getPublicizingRequests() } + /** + * Contest의 소속 Group을 Open Space(groupId === 1)로 이동시키기 위한 요청(Publicizing Request)를 생성합니다. + * @param groupId Contest가 속한 Group의 ID. 이미 Open Space(groupId === 1)이 아니어야 합니다. + * @param contestId Contest의 ID + * @returns 생성된 Publicizing Request + */ @Mutation(() => PublicizingRequest) async createPublicizingRequest( @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, @Args('contestId', { type: () => Int }) contestId: number ) { - try { - return await this.contestService.createPublicizingRequest( - groupId, - contestId - ) - } catch (error) { - if ( - error instanceof EntityNotExistException || - error instanceof ConflictFoundException - ) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.createPublicizingRequest( + groupId, + contestId + ) } + /** + * Contest의 소속 Group을 Open Space(groupId === 1)로 이동시키기 위한 요청(Publicizing Request)을 처리합니다. + * @param contestId Publicizing Request를 생성한 contest의 Id + * @param isAccepted 요청 수락 여부 + * @returns + */ @Mutation(() => PublicizingResponse) @UseRolesGuard() async handlePublicizingRequest( @Args('contestId', { type: () => Int }) contestId: number, @Args('isAccepted', ParseBoolPipe) isAccepted: boolean ) { - try { - return await this.contestService.handlePublicizingRequest( - contestId, - isAccepted - ) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.handlePublicizingRequest( + contestId, + isAccepted + ) } @Mutation(() => [ContestProblem]) @@ -193,22 +135,11 @@ export class ContestResolver { @Args('problemIdsWithScore', { type: () => [ProblemScoreInput] }) problemIdsWithScore: ProblemScoreInput[] ) { - try { - return await this.contestService.importProblemsToContest( - groupId, - contestId, - problemIdsWithScore - ) - } catch (error) { - if ( - error instanceof EntityNotExistException || - error instanceof UnprocessableDataException - ) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.importProblemsToContest( + groupId, + contestId, + problemIdsWithScore + ) } @Mutation(() => [ContestProblem]) @@ -218,29 +149,18 @@ export class ContestResolver { contestId: number, @Args('problemIds', { type: () => [Int] }) problemIds: number[] ) { - try { - return await this.contestService.removeProblemsFromContest( - groupId, - contestId, - problemIds - ) - } catch (error) { - if ( - error instanceof EntityNotExistException || - error instanceof UnprocessableDataException - ) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.removeProblemsFromContest( + groupId, + contestId, + problemIds + ) } /** * 특정 User의 Contest 제출 내용 요약 정보를 가져옵니다. * * Contest Overall 페이지에서 특정 유저를 선택했을 때 사용 - * https://github.com/skkuding/codedang/pull/1894 + * @see https://github.com/skkuding/codedang/pull/1894 */ @Query(() => ContestSubmissionSummaryForUser) async getContestSubmissionSummaryByUserId( @@ -257,18 +177,13 @@ export class ContestResolver { @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) cursor: number | null ) { - try { - return await this.contestService.getContestSubmissionSummaryByUserId( - take, - contestId, - userId, - problemId, - cursor - ) - } catch (error) { - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.getContestSubmissionSummaryByUserId( + take, + contestId, + userId, + problemId, + cursor + ) } @Mutation(() => DuplicatedContestResponse) @@ -278,29 +193,18 @@ export class ContestResolver { contestId: number, @Context('req') req: AuthenticatedRequest ) { - try { - return await this.contestService.duplicateContest( - groupId, - contestId, - req.user.id - ) - } catch (error) { - if ( - error instanceof UnprocessableDataException || - error instanceof EntityNotExistException - ) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.duplicateContest( + groupId, + contestId, + req.user.id + ) } /** * Contest에 참여한 User와, 점수 요약을 함께 불러옵니다. * * Contest Overall 페이지의 Participants 탭의 정보 - * https://github.com/skkuding/codedang/pull/2029 + * @see https://github.com/skkuding/codedang/pull/2029 */ @Query(() => [UserContestScoreSummaryWithUserInfo]) async getContestScoreSummaries( @@ -313,34 +217,18 @@ export class ContestResolver { @Args('searchingName', { type: () => String, nullable: true }) searchingName?: string ) { - try { - return await this.contestService.getContestScoreSummaries( - contestId, - take, - cursor, - searchingName - ) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.getContestScoreSummaries( + contestId, + take, + cursor, + searchingName + ) } @Query(() => ContestsGroupedByStatus) async getContestsByProblemId( @Args('problemId', { type: () => Int }) problemId: number ) { - try { - return await this.contestService.getContestsByProblemId(problemId) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.contestService.getContestsByProblemId(problemId) } } diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index 3ba76eeb14..0bb7b4de96 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' @@ -58,7 +59,7 @@ export class ContestService { } async getContest(contestId: number) { - const { _count, ...data } = await this.prisma.contest.findFirstOrThrow({ + const contest = await this.prisma.contest.findFirst({ where: { id: contestId }, @@ -70,6 +71,12 @@ export class ContestService { } }) + if (!contest) { + throw new EntityNotExistException('contest') + } + + const { _count, ...data } = contest + return { ...data, participants: _count.contestRecord @@ -96,15 +103,17 @@ export class ContestService { throw new EntityNotExistException('Group') } - const newContest: Contest = await this.prisma.contest.create({ - data: { - createdById: userId, - groupId, - ...contest - } - }) - - return newContest + try { + return await this.prisma.contest.create({ + data: { + createdById: userId, + groupId, + ...contest + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } } async updateContest( @@ -184,21 +193,25 @@ export class ContestService { visibleLockTime } }) - } catch (error) { + } catch { continue } } } - return await this.prisma.contest.update({ - where: { - id: contest.id - }, - data: { - title: contest.title, - ...contest - } - }) + try { + return await this.prisma.contest.update({ + where: { + id: contest.id + }, + data: { + title: contest.title, + ...contest + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } } async deleteContest(groupId: number, contestId: number) { @@ -226,11 +239,15 @@ export class ContestService { await this.removeProblemsFromContest(groupId, contestId, problemIds) } - return await this.prisma.contest.delete({ - where: { - id: contestId - } - }) + try { + return await this.prisma.contest.delete({ + where: { + id: contestId + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } } async getPublicizingRequests() { @@ -277,16 +294,17 @@ export class ContestService { ) if (isAccepted) { - const updatedContest = await this.prisma.contest.update({ - where: { - id: contestId - }, - data: { - groupId: OPEN_SPACE_ID - } - }) - if (!updatedContest) { - throw new EntityNotExistException('contest') + try { + await this.prisma.contest.update({ + where: { + id: contestId + }, + data: { + groupId: OPEN_SPACE_ID + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) } } @@ -362,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[] = [] @@ -382,44 +395,48 @@ export class ContestService { continue } - const [contestProblem] = await this.prisma.$transaction([ - this.prisma.contestProblem.create({ - data: { - // 원래 id: 'temp'이었는데, contestProblem db schema field가 바뀌어서 - // 임시 방편으로 order: 0으로 설정합니다. - order: 0, - contestId, - problemId, - score - } - }), - this.prisma.problem.updateMany({ - where: { - id: problemId, - OR: [ - { - visibleLockTime: { - equals: MIN_DATE - } - }, - { - visibleLockTime: { - equals: MAX_DATE - } - }, - { - visibleLockTime: { - lte: contest.endTime + try { + const [contestProblem] = await this.prisma.$transaction([ + this.prisma.contestProblem.create({ + data: { + // 원래 id: 'temp'이었는데, contestProblem db schema field가 바뀌어서 + // 임시 방편으로 order: 0으로 설정합니다. + order: 0, + contestId, + problemId, + score + } + }), + this.prisma.problem.updateMany({ + where: { + id: problemId, + OR: [ + { + visibleLockTime: { + equals: MIN_DATE + } + }, + { + visibleLockTime: { + equals: MAX_DATE + } + }, + { + visibleLockTime: { + lte: contest.endTime + } } - } - ] - }, - data: { - visibleLockTime: contest.endTime - } - }) - ]) - contestProblems.push(contestProblem) + ] + }, + data: { + visibleLockTime: contest.endTime + } + }) + ]) + contestProblems.push(contestProblem) + } catch (error) { + throw new UnprocessableDataException(error.message) + } } return contestProblems @@ -446,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[] = [] @@ -469,7 +481,7 @@ export class ContestService { .map((contestProblem) => contestProblem.contestId) if (contestIds.length) { - const latestContest = await this.prisma.contest.findFirstOrThrow({ + const latestContest = await this.prisma.contest.findFirst({ where: { id: { in: contestIds @@ -482,33 +494,42 @@ export class ContestService { endTime: true } }) + + if (!latestContest) { + throw new EntityNotExistException('contest') + } + visibleLockTime = latestContest.endTime } - const [, contestProblem] = await this.prisma.$transaction([ - this.prisma.problem.updateMany({ - where: { - id: problemId, - visibleLockTime: { - lte: contest.endTime + try { + const [, contestProblem] = await this.prisma.$transaction([ + this.prisma.problem.updateMany({ + where: { + id: problemId, + visibleLockTime: { + lte: contest.endTime + } + }, + data: { + visibleLockTime } - }, - data: { - visibleLockTime - } - }), - this.prisma.contestProblem.delete({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - contestId_problemId: { - contestId, - problemId + }), + this.prisma.contestProblem.delete({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestId_problemId: { + contestId, + problemId + } } - } - }) - ]) + }) + ]) - contestProblems.push(contestProblem) + contestProblems.push(contestProblem) + } catch (error) { + throw new UnprocessableDataException(error.message) + } } return contestProblems @@ -622,51 +643,56 @@ export class ContestService { const { id, createTime, updateTime, title, ...contestDataToCopy } = contestFound - const [newContest, newContestProblems, newContestRecords] = - await this.prisma.$transaction(async (tx) => { - // 1. copy contest - const newContest = await tx.contest.create({ - data: { - ...contestDataToCopy, - title: 'Copy of ' + title, - createdById: userId, - groupId, - isVisible: newVisible - } - }) + try { + const [newContest, newContestProblems, newContestRecords] = + await this.prisma.$transaction(async (tx) => { + // 1. copy contest + const newContest = await tx.contest.create({ + data: { + ...contestDataToCopy, + title: 'Copy of ' + title, + createdById: userId, + groupId, + isVisible: newVisible + } + }) - // 2. copy contest problems - const newContestProblems = await Promise.all( - contestProblemsFound.map((contestProblem) => - tx.contestProblem.create({ - data: { - order: contestProblem.order, - contestId: newContest.id, - problemId: contestProblem.problemId - } - }) + // 2. copy contest problems + const newContestProblems = await Promise.all( + contestProblemsFound.map((contestProblem) => + tx.contestProblem.create({ + data: { + order: contestProblem.order, + contestId: newContest.id, + problemId: contestProblem.problemId, + score: contestProblem.score + } + }) + ) ) - ) - - // 3. copy contest records (users who participated in the contest) - const newContestRecords = await Promise.all( - userContestRecords.map((userContestRecord) => - tx.contestRecord.create({ - data: { - contestId: newContest.id, - userId: userContestRecord.userId - } - }) + + // 3. copy contest records (users who participated in the contest) + const newContestRecords = await Promise.all( + userContestRecords.map((userContestRecord) => + tx.contestRecord.create({ + data: { + contestId: newContest.id, + userId: userContestRecord.userId + } + }) + ) ) - ) - return [newContest, newContestProblems, newContestRecords] - }) + return [newContest, newContestProblems, newContestRecords] + }) - return { - contest: newContest, - problems: newContestProblems, - records: newContestRecords + return { + contest: newContest, + problems: newContestProblems, + records: newContestRecords + } + } catch (error) { + throw new UnprocessableDataException(error.message) } } @@ -674,7 +700,7 @@ export class ContestService { * 특정 user의 특정 Contest에 대한 총점, 통과한 문제 개수와 각 문제별 테스트케이스 통과 개수를 불러옵니다. */ async getContestScoreSummary(userId: number, contestId: number) { - const [contestProblems, submissions, contestRecord] = await Promise.all([ + const [contestProblems, rawSubmissions] = await Promise.all([ this.prisma.contestProblem.findMany({ where: { contestId @@ -688,19 +714,17 @@ export class ContestService { orderBy: { createTime: 'desc' } - }), - this.prisma.contestRecord.findFirst({ - where: { - userId, - contestId - } }) ]) - if (!contestProblems.length) { - throw new EntityNotExistException('ContestProblems') - } else if (!contestRecord) { - throw new EntityNotExistException('contestRecord') - } + + // 오직 현재 Contest에 남아있는 문제들의 제출에 대해서만 ScoreSummary 계산 + const contestProblemIds = contestProblems.map( + (contestProblem) => contestProblem.problemId + ) + const submissions = rawSubmissions.filter((submission) => + contestProblemIds.includes(submission.problemId) + ) + if (!submissions.length) { return { submittedProblemCount: 0, @@ -713,6 +737,7 @@ export class ContestService { problemScores: [] } } + // 하나의 Problem에 대해 여러 개의 Submission이 존재한다면, 마지막에 제출된 Submission만을 점수 계산에 반영함 const latestSubmissions: { [problemId: string]: { @@ -831,7 +856,8 @@ export class ContestService { select: { realName: true } - } + }, + major: true } } }, @@ -849,6 +875,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 @@ -866,16 +893,21 @@ export class ContestService { problemId }, select: { - contest: true + contest: true, + score: true } }) - if (!contestProblems.length) { - 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() @@ -894,12 +926,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/group/group.resolver.ts b/apps/backend/apps/admin/src/group/group.resolver.ts index 31eeb01d74..04d6d654b0 100644 --- a/apps/backend/apps/admin/src/group/group.resolver.ts +++ b/apps/backend/apps/admin/src/group/group.resolver.ts @@ -1,13 +1,7 @@ -import { InternalServerErrorException, Logger } from '@nestjs/common' import { Args, Int, Query, Mutation, Resolver, Context } from '@nestjs/graphql' import { Group } from '@generated' import { Role } from '@prisma/client' import { AuthenticatedRequest, UseRolesGuard } from '@libs/auth' -import { - ConflictFoundException, - DuplicateFoundException, - ForbiddenAccessException -} from '@libs/exception' import { CursorValidationPipe, GroupIDPipe } from '@libs/pipe' import { GroupService } from './group.service' import { CreateGroupInput, UpdateGroupInput } from './model/group.input' @@ -15,8 +9,6 @@ import { DeletedUserGroup, FindGroup } from './model/group.output' @Resolver(() => Group) export class GroupResolver { - private readonly logger = new Logger(GroupResolver.name) - constructor(private readonly groupService: GroupService) {} @Mutation(() => Group) @@ -25,15 +17,7 @@ export class GroupResolver { @Context('req') req: AuthenticatedRequest, @Args('input') input: CreateGroupInput ) { - try { - return await this.groupService.createGroup(input, req.user.id) - } catch (error) { - if (error instanceof DuplicateFoundException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.createGroup(input, req.user.id) } @Query(() => [FindGroup]) @@ -58,18 +42,7 @@ export class GroupResolver { @Args('groupId', { type: () => Int }, GroupIDPipe) id: number, @Args('input') input: UpdateGroupInput ) { - try { - return await this.groupService.updateGroup(id, input) - } catch (error) { - if ( - error instanceof DuplicateFoundException || - error instanceof ForbiddenAccessException - ) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.updateGroup(id, input) } @Mutation(() => DeletedUserGroup) @@ -77,41 +50,30 @@ export class GroupResolver { @Context('req') req: AuthenticatedRequest, @Args('groupId', { type: () => Int }, GroupIDPipe) id: number ) { - try { - return await this.groupService.deleteGroup(id, req.user) - } catch (error) { - if (error instanceof ForbiddenAccessException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.deleteGroup(id, req.user) } + /** + * Group 초대코드를 발급합니다. + * @param id 초대코드를 발급하는 Group의 ID + * @returns 발급된 초대코드 + */ @Mutation(() => String) async issueInvitation( @Args('groupId', { type: () => Int }, GroupIDPipe) id: number ) { - try { - return await this.groupService.issueInvitation(id) - } catch (error) { - if (error instanceof ConflictFoundException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.issueInvitation(id) } + /** + * 발급했던 Group 초대코드를 제거합니다. + * @param id 초대코드를 제거하는 Group의 ID + * @returns 제거된 초대코드 + */ @Mutation(() => String) async revokeInvitation( @Args('groupId', { type: () => Int }, GroupIDPipe) id: number ) { - try { - return await this.groupService.revokeInvitation(id) - } catch (error) { - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.revokeInvitation(id) } } diff --git a/apps/backend/apps/admin/src/group/group.service.ts b/apps/backend/apps/admin/src/group/group.service.ts index 0c8b67e148..48cb9896ba 100644 --- a/apps/backend/apps/admin/src/group/group.service.ts +++ b/apps/backend/apps/admin/src/group/group.service.ts @@ -8,7 +8,8 @@ import { ConflictFoundException, DuplicateFoundException, EntityNotExistException, - ForbiddenAccessException + ForbiddenAccessException, + UnprocessableDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' import type { CreateGroupInput, UpdateGroupInput } from './model/group.input' @@ -33,25 +34,29 @@ export class GroupService { input.config.allowJoinFromSearch = false } - const group = await this.prisma.group.create({ - data: { - groupName: input.groupName, - description: input.description, - config: JSON.stringify(input.config) - } - }) - await this.prisma.userGroup.create({ - data: { - user: { - connect: { id: userId } - }, - group: { - connect: { id: group.id } - }, - isGroupLeader: true - } - }) - return group + try { + const group = await this.prisma.group.create({ + data: { + groupName: input.groupName, + description: input.description, + config: JSON.stringify(input.config) + } + }) + await this.prisma.userGroup.create({ + data: { + user: { + connect: { id: userId } + }, + group: { + connect: { id: group.id } + }, + isGroupLeader: true + } + }) + return group + } catch (error) { + throw new UnprocessableDataException(error.message) + } } async getGroups(cursor: number | null, take: number) { @@ -133,16 +138,21 @@ export class GroupService { if (!input.config.showOnList) { input.config.allowJoinFromSearch = false } - return await this.prisma.group.update({ - where: { - id - }, - data: { - groupName: input.groupName, - description: input.description, - config: JSON.stringify(input.config) - } - }) + + try { + return await this.prisma.group.update({ + where: { + id + }, + data: { + groupName: input.groupName, + description: input.description, + config: JSON.stringify(input.config) + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } } async deleteGroup(id: number, user: AuthenticatedUser) { diff --git a/apps/backend/apps/admin/src/notice/notice.resolver.ts b/apps/backend/apps/admin/src/notice/notice.resolver.ts index 49be1822fa..4e72977a40 100644 --- a/apps/backend/apps/admin/src/notice/notice.resolver.ts +++ b/apps/backend/apps/admin/src/notice/notice.resolver.ts @@ -1,8 +1,4 @@ -import { - InternalServerErrorException, - Logger, - NotFoundException -} from '@nestjs/common' +import { Logger } from '@nestjs/common' import { Args, Context, @@ -15,7 +11,6 @@ import { } from '@nestjs/graphql' import { Group, Notice, User } from '@generated' import { AuthenticatedRequest } from '@libs/auth' -import { EntityNotExistException } from '@libs/exception' import { CursorValidationPipe, GroupIDPipe, IDValidationPipe } from '@libs/pipe' import { GroupService } from '@admin/group/group.service' import { UserService } from '@admin/user/user.service' @@ -38,15 +33,7 @@ export class NoticeResolver { groupId: number, @Context('req') req: AuthenticatedRequest ) { - try { - return await this.noticeService.createNotice(req.user.id, groupId, input) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw new NotFoundException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.noticeService.createNotice(req.user.id, groupId, input) } @Mutation(() => Notice) @@ -55,15 +42,7 @@ export class NoticeResolver { groupId: number, @Args('noticeId', { type: () => Int }, IDValidationPipe) noticeId: number ) { - try { - return await this.noticeService.deleteNotice(groupId, noticeId) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw new NotFoundException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.noticeService.deleteNotice(groupId, noticeId) } @Mutation(() => Notice) @@ -73,15 +52,7 @@ export class NoticeResolver { @Args('noticeId', { type: () => Int }, IDValidationPipe) noticeId: number, @Args('input') input: UpdateNoticeInput ) { - try { - return await this.noticeService.updateNotice(groupId, noticeId, input) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw new NotFoundException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.noticeService.updateNotice(groupId, noticeId, input) } @Query(() => Notice) @@ -90,15 +61,7 @@ export class NoticeResolver { groupId: number, @Args('noticeId', { type: () => Int }, IDValidationPipe) noticeId: number ) { - try { - return await this.noticeService.getNotice(groupId, noticeId) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw new NotFoundException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.noticeService.getNotice(groupId, noticeId) } @Query(() => [Notice], { nullable: 'items' }) @@ -110,42 +73,20 @@ export class NoticeResolver { @Args('take', { type: () => Int, defaultValue: 10 }) take: number ) { - try { - return await this.noticeService.getNotices(groupId, cursor, take) - } catch (error) { - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.noticeService.getNotices(groupId, cursor, take) } @ResolveField('createdBy', () => User) async getUser(@Parent() notice: Notice) { - try { - const { createdById } = notice - if (createdById == null) { - return null - } - return this.userService.getUser(createdById) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw new NotFoundException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() + const { createdById } = notice + if (createdById == null) { + return null } + return this.userService.getUser(createdById) } @ResolveField('group', () => Group) async getGroup(@Parent() notice: Notice) { - try { - const { groupId } = notice - return this.groupService.getGroupById(groupId) - } catch (error) { - if (error instanceof EntityNotExistException) { - throw new NotFoundException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return this.groupService.getGroupById(notice.groupId) } } diff --git a/apps/backend/apps/admin/src/notice/notice.service.spec.ts b/apps/backend/apps/admin/src/notice/notice.service.spec.ts index eb6d5019c9..3176ab825e 100644 --- a/apps/backend/apps/admin/src/notice/notice.service.spec.ts +++ b/apps/backend/apps/admin/src/notice/notice.service.spec.ts @@ -4,7 +4,6 @@ import { faker } from '@faker-js/faker' import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' import { expect } from 'chai' import { stub } from 'sinon' -import { EntityNotExistException } from '@libs/exception' import { PrismaService } from '@libs/prisma' import type { CreateNoticeInput, UpdateNoticeInput } from './model/notice.input' import { NoticeService } from './notice.service' @@ -49,6 +48,7 @@ const db = { notice: { findFirst: stub(), findMany: stub(), + findUniqueOrThrow: stub(), create: stub(), delete: stub(), update: stub() @@ -86,6 +86,7 @@ describe('NoticeService', () => { afterEach(() => { db.notice.findFirst.reset() db.notice.findMany.reset() + db.notice.findUniqueOrThrow.reset() db.notice.create.reset() db.notice.delete.reset() db.notice.update.reset() @@ -107,7 +108,7 @@ describe('NoticeService', () => { db.notice.create.rejects(foreignKeyFailedPrismaError) await expect( service.createNotice(failGroupId, userId, createNoticeInput) - ).to.be.rejectedWith(EntityNotExistException) + ).to.be.rejectedWith(PrismaClientKnownRequestError) }) }) @@ -123,7 +124,7 @@ describe('NoticeService', () => { db.notice.update.rejects(relatedRecordsNotFoundPrismaError) await expect( service.updateNotice(failGroupId, noticeId, updateNoticeInput) - ).to.be.rejectedWith(EntityNotExistException) + ).to.be.rejectedWith(PrismaClientKnownRequestError) }) }) @@ -139,7 +140,7 @@ describe('NoticeService', () => { db.notice.delete.rejects(relatedRecordsNotFoundPrismaError) await expect( service.deleteNotice(failGroupId, noticeId) - ).to.be.rejectedWith(EntityNotExistException) + ).to.be.rejectedWith(PrismaClientKnownRequestError) }) }) @@ -153,13 +154,14 @@ describe('NoticeService', () => { describe('getNotice', () => { it('should return a notice', async () => { - db.notice.findFirst.resolves(notice) + db.notice.findUniqueOrThrow.resolves(notice) expect(await service.getNotice(groupId, noticeId)).to.deep.equal(notice) }) it('should throw error when notice not found', async () => { + db.notice.findUniqueOrThrow.rejects(relatedRecordsNotFoundPrismaError) await expect(service.getNotice(failGroupId, noticeId)).to.be.rejectedWith( - EntityNotExistException + PrismaClientKnownRequestError ) }) }) diff --git a/apps/backend/apps/admin/src/notice/notice.service.ts b/apps/backend/apps/admin/src/notice/notice.service.ts index ef87812d2d..1060c1bf5d 100644 --- a/apps/backend/apps/admin/src/notice/notice.service.ts +++ b/apps/backend/apps/admin/src/notice/notice.service.ts @@ -1,6 +1,4 @@ import { Injectable } from '@nestjs/common' -import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' -import { EntityNotExistException } from '@libs/exception' import { PrismaService } from '@libs/prisma' import type { CreateNoticeInput, UpdateNoticeInput } from './model/notice.input' @@ -13,42 +11,22 @@ export class NoticeService { groupId: number, createNoticeInput: CreateNoticeInput ) { - try { - return await this.prisma.notice.create({ - data: { - createdById: userId, - groupId, - ...createNoticeInput - } - }) - } catch (error) { - if ( - error instanceof PrismaClientKnownRequestError && - error.code === 'P2003' - ) { - throw new EntityNotExistException('Group') + return await this.prisma.notice.create({ + data: { + createdById: userId, + groupId, + ...createNoticeInput } - throw error - } + }) } async deleteNotice(groupId: number, noticeId: number) { - try { - return await this.prisma.notice.delete({ - where: { - id: noticeId, - groupId - } - }) - } catch (error) { - if ( - error instanceof PrismaClientKnownRequestError && - error.code === 'P2025' - ) { - throw new EntityNotExistException('Notice') + return await this.prisma.notice.delete({ + where: { + id: noticeId, + groupId } - throw error - } + }) } async updateNotice( @@ -56,36 +34,22 @@ export class NoticeService { noticeId: number, updateNoticeInput: UpdateNoticeInput ) { - try { - return await this.prisma.notice.update({ - where: { - id: noticeId, - groupId - }, - data: updateNoticeInput - }) - } catch (error) { - if ( - error instanceof PrismaClientKnownRequestError && - error.code === 'P2025' - ) { - throw new EntityNotExistException('Notice') - } - throw error - } + return await this.prisma.notice.update({ + where: { + id: noticeId, + groupId + }, + data: updateNoticeInput + }) } async getNotice(groupId: number, noticeId: number) { - const notice = await this.prisma.notice.findFirst({ + return await this.prisma.notice.findUniqueOrThrow({ where: { id: noticeId, groupId } }) - if (notice == null) { - throw new EntityNotExistException('Notice') - } - return notice } async getNotices(groupId: number, cursor: number | null, take: 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/storage/storage.service.ts b/apps/backend/apps/admin/src/storage/storage.service.ts index efe6cc7425..9b362d58d5 100644 --- a/apps/backend/apps/admin/src/storage/storage.service.ts +++ b/apps/backend/apps/admin/src/storage/storage.service.ts @@ -17,6 +17,14 @@ export class StorageService { @Inject('S3_CLIENT_MEDIA') private readonly mediaClient: S3Client ) {} + /** + * @deprecated testcase를 더 이상 S3에 저장하지 않습니다. + * + * Object(testcase)를 업로드합니다. + * @param filename 파일 이름 + * @param content 파일 내용 + * @param type 업로드할 파일의 MIME type + */ async uploadObject(filename: string, content: string, type: ContentType) { await this.client.send( new PutObjectCommand({ @@ -28,6 +36,13 @@ export class StorageService { ) } + /** + * 이미지를 S3 Bucket에 업로드합니다. + * @param filename 이미지 파일 이름 + * @param fileSize 이미지 파일 크기 (Byte) + * @param content 이미지 파일 내용 (ReadStream type) + * @param type 업로드할 이미지 파일의 MIME type + */ async uploadImage( filename: string, fileSize: number, @@ -47,6 +62,13 @@ export class StorageService { // TODO: uploadFile + /** + * @deprecated testcase를 더 이상 S3에 저장하지 않습니다. + * + * Object(testcase)를 불러옵니다. + * @param filename 파일 이름 + * @returns S3에 저장된 Object + */ async readObject(filename: string) { const res = await this.client.send( new GetObjectCommand({ @@ -57,6 +79,12 @@ export class StorageService { return res.Body?.transformToString() ?? '' } + /** + * @deprecated testcase를 더 이상 S3에 저장하지 않습니다. + * + * S3에 저장된 Object(testcase)를 삭제합니다. + * @param filename 파일 이름 + */ async deleteObject(filename: string) { await this.client.send( new DeleteObjectCommand({ @@ -66,6 +94,10 @@ export class StorageService { ) } + /** + * S3에 저장된 이미지를 삭제합니다. + * @param filename 이미지 파일 이름 + */ async deleteImage(filename: string) { await this.mediaClient.send( new DeleteObjectCommand({ 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/group/group.controller.ts b/apps/backend/apps/client/src/group/group.controller.ts index 8a6815d413..09a35c29a8 100644 --- a/apps/backend/apps/client/src/group/group.controller.ts +++ b/apps/backend/apps/client/src/group/group.controller.ts @@ -3,26 +3,18 @@ import { DefaultValuePipe, Delete, Get, - InternalServerErrorException, Logger, - NotFoundException, Param, Post, Query, Req, UseGuards } from '@nestjs/common' -import { Prisma } from '@prisma/client' import { AuthenticatedRequest, AuthNotNeededIfOpenSpace, GroupMemberGuard } from '@libs/auth' -import { - ConflictFoundException, - EntityNotExistException, - ForbiddenAccessException -} from '@libs/exception' import { CursorValidationPipe, GroupIDPipe, RequiredIntPipe } from '@libs/pipe' import { GroupService } from './group.service' @@ -39,22 +31,12 @@ export class GroupController { @Query('take', new DefaultValuePipe(10), new RequiredIntPipe('take')) take: number ) { - try { - return await this.groupService.getGroups(cursor, take) - } catch (error) { - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.getGroups(cursor, take) } @Get('joined') async getJoinedGroups(@Req() req: AuthenticatedRequest) { - try { - return await this.groupService.getJoinedGroups(req.user.id) - } catch (error) { - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.getJoinedGroups(req.user.id) } @Get(':groupId') @@ -62,18 +44,7 @@ export class GroupController { @Req() req: AuthenticatedRequest, @Param('groupId', GroupIDPipe) groupId: number ) { - try { - return await this.groupService.getGroup(groupId, req.user.id) - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.name === 'NotFoundError' - ) { - throw new NotFoundException(error.message) - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.getGroup(groupId, req.user.id) } @Get('invite/:invitation') @@ -81,23 +52,7 @@ export class GroupController { @Req() req: AuthenticatedRequest, @Param('invitation') invitation: string ) { - try { - return await this.groupService.getGroupByInvitation( - invitation, - req.user.id - ) - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.name === 'NotFoundError' - ) { - throw new NotFoundException('Invalid invitation') - } else if (error instanceof EntityNotExistException) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.getGroupByInvitation(invitation, req.user.id) } @Post(':groupId/join') @@ -106,27 +61,11 @@ export class GroupController { @Param('groupId', GroupIDPipe) groupId: number, @Query('invitation') invitation?: string ) { - try { - return await this.groupService.joinGroupById( - req.user.id, - groupId, - invitation - ) - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.name === 'NotFoundError' - ) { - throw new NotFoundException(error.message) - } else if ( - error instanceof ForbiddenAccessException || - error instanceof ConflictFoundException - ) { - throw error.convert2HTTPException() - } - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.joinGroupById( + req.user.id, + groupId, + invitation + ) } @Delete(':groupId/leave') @@ -135,37 +74,18 @@ export class GroupController { @Req() req: AuthenticatedRequest, @Param('groupId', GroupIDPipe) groupId: number ) { - try { - return await this.groupService.leaveGroup(req.user.id, groupId) - } catch (error) { - if (error instanceof ConflictFoundException) { - throw error.convert2HTTPException() - } - - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.leaveGroup(req.user.id, groupId) } @Get(':groupId/leaders') @UseGuards(GroupMemberGuard) async getGroupLeaders(@Param('groupId', GroupIDPipe) groupId: number) { - try { - return await this.groupService.getGroupLeaders(groupId) - } catch (error) { - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.getGroupLeaders(groupId) } @Get(':groupId/members') @UseGuards(GroupMemberGuard) async getGroupMembers(@Param('groupId', GroupIDPipe) groupId: number) { - try { - return await this.groupService.getGroupMembers(groupId) - } catch (error) { - this.logger.error(error) - throw new InternalServerErrorException() - } + return await this.groupService.getGroupMembers(groupId) } } diff --git a/apps/backend/apps/client/src/group/group.service.ts b/apps/backend/apps/client/src/group/group.service.ts index c6aedc41fd..d6e7ca34a7 100644 --- a/apps/backend/apps/client/src/group/group.service.ts +++ b/apps/backend/apps/client/src/group/group.service.ts @@ -77,7 +77,6 @@ export class GroupService { async getGroupByInvitation(code: string, userId: number) { const groupId = await this.cacheManager.get(invitationCodeKey(code)) - if (!groupId) { throw new EntityNotExistException('Invalid invitation') } @@ -329,7 +328,7 @@ export class GroupService { } async createUserGroup(userGroupData: UserGroupData): Promise { - const user = await this.prisma.user.findUnique({ + const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userGroupData.userId } 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/class/judger-response.dto.ts b/apps/backend/apps/client/src/submission/class/judger-response.dto.ts index efa3a6a7c4..dabf3e77fd 100644 --- a/apps/backend/apps/client/src/submission/class/judger-response.dto.ts +++ b/apps/backend/apps/client/src/submission/class/judger-response.dto.ts @@ -17,6 +17,7 @@ class JudgeResult { signal: number exitCode: number errorCode: number + output?: string } export class JudgerResponse { 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 a7bf1ec79f..195ec6555e 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,26 +88,71 @@ 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 testcases = - (await this.cacheManager.get< - { - id: number - result: ResultStatus - }[] - >(key)) ?? [] - - testcases.forEach((tc) => { - if (!testcaseId || tc.id === testcaseId) { - tc.result = status + const output = this.parseError(msg, status) + 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, + // TODO: judgeResult 코드 처리 통합 해야함 + result: msg.judgeResult + ? Status(msg.judgeResult.resultCode) + : Status(msg.resultCode), + 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 + // TODO: judgeResult 코드 처리 통합 해야함 + testcase.result = msg.judgeResult + ? Status(msg.judgeResult.resultCode) + : Status(msg.resultCode) + 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 { + if (msg.judgeResult?.output) return msg.judgeResult.output + + switch (status) { + case ResultStatus.CompileError: + return msg.error ?? '' + case ResultStatus.SegmentationFaultError: + return 'Segmentation Fault' + case ResultStatus.RuntimeError: + return 'Value Error' + default: + return '' + } } async validateJudgerResponse(msg: object): Promise { @@ -123,7 +181,8 @@ export class SubmissionSubscriptionService implements OnModuleInit { const submissionResult = { submissionId: msg.submissionId, problemTestcaseId: msg.judgeResult.testcaseId, - result: status, + // TODO: judgeResult 코드 처리 통합 해야함 + result: Status(msg.judgeResult.resultCode), cpuTime: BigInt(msg.judgeResult.cpuTime), memoryUsage: msg.judgeResult.memory } 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 7932d5ae04..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,39 +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 - }[] - >(key) + output?: string + }>(key) + if (testcase) { + results.push(testcase) + } + } + return results } @Span() diff --git a/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts index 5b686f6260..5597b90158 100644 --- a/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts @@ -34,7 +34,8 @@ const judgeResult = { memory: 10000000, signal: 0, exitCode: 0, - errorCode: 0 + errorCode: 0, + output: undefined } const msg = { diff --git a/apps/backend/apps/client/src/user/dto/signup.dto.ts b/apps/backend/apps/client/src/user/dto/signup.dto.ts index 388a83277c..465effa018 100644 --- a/apps/backend/apps/client/src/user/dto/signup.dto.ts +++ b/apps/backend/apps/client/src/user/dto/signup.dto.ts @@ -3,6 +3,7 @@ import { IsEmail, IsNotEmpty, IsNumberString, + IsOptional, IsString, Matches } from 'class-validator' @@ -24,9 +25,11 @@ export class SignUpDto { @IsNotEmpty() readonly realName: string + @IsOptional() @IsNumberString() readonly studentId?: string + @IsOptional() @IsString() readonly major?: string } 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 f7e0e7955c..1c2a3d22a2 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -18,36 +18,36 @@ "studio:test": "cross-env DATABASE_URL=$TEST_DATABASE_URL npx prisma studio" }, "dependencies": { - "@apollo/server": "^4.11.0", - "@aws-sdk/client-s3": "^3.658.1", - "@aws-sdk/client-ses": "^3.658.1", - "@aws-sdk/credential-provider-node": "^3.658.1", - "@golevelup/nestjs-rabbitmq": "^5.5.0", + "@apollo/server": "^4.11.2", + "@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.0", - "@nestjs/axios": "^3.0.3", - "@nestjs/cache-manager": "^2.2.2", - "@nestjs/common": "^10.4.4", - "@nestjs/config": "^3.2.3", - "@nestjs/core": "^10.4.4", - "@nestjs/graphql": "^12.2.0", + "@nestjs/apollo": "^12.2.1", + "@nestjs/axios": "^3.1.2", + "@nestjs/cache-manager": "^2.3.0", + "@nestjs/common": "^10.4.7", + "@nestjs/config": "^3.3.0", + "@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.4", + "@nestjs/platform-express": "^10.4.7", "@nestjs/swagger": "^7.4.2", "@opentelemetry/api": "~1.9.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.53.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.53.0", - "@opentelemetry/host-metrics": "^0.35.3", - "@opentelemetry/instrumentation-express": "^0.42.0", - "@opentelemetry/instrumentation-http": "^0.53.0", - "@opentelemetry/resources": "^1.26.0", - "@opentelemetry/sdk-metrics": "^1.26.0", - "@opentelemetry/sdk-node": "^0.53.0", - "@opentelemetry/sdk-trace-node": "^1.26.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.2", + "@opentelemetry/resources": "^1.27.0", + "@opentelemetry/sdk-metrics": "^1.27.0", + "@opentelemetry/sdk-node": "^0.54.2", + "@opentelemetry/sdk-trace-node": "^1.27.0", "@opentelemetry/semantic-conventions": "^1.27.0", - "@prisma/client": "^5.20.0", - "@prisma/instrumentation": "~5.20.0", + "@prisma/client": "^5.22.0", + "@prisma/instrumentation": "~5.22.0", "argon2": "^0.41.1", "axios": "^1.7.7", "cache-manager": "^5.7.6", @@ -56,11 +56,11 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "colorette": "^2.0.20", - "cookie-parser": "^1.4.6", + "cookie-parser": "^1.4.7", "cross-env": "^7.0.3", "dayjs": "^1.11.13", "exceljs": "^4.4.0", - "express": "^4.21.0", + "express": "^4.21.1", "generate-password": "^1.7.1", "graphql": "^16.9.0", "graphql-type-json": "^0.3.2", @@ -68,43 +68,43 @@ "handlebars": "^4.7.8", "nestjs-otel": "^6.1.1", "nestjs-pino": "^4.1.0", - "nodemailer": "^6.9.15", + "nodemailer": "^6.9.16", "passport": "^0.7.0", "passport-github2": "^0.1.12", "passport-jwt": "^4.0.1", "passport-kakao": "^1.0.1", "pino-http": "^10.3.0", - "pino-pretty": "^11.2.2", + "pino-pretty": "^11.3.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", - "sql-formatter": "^15.4.2", + "sql-formatter": "^15.4.6", "zod": "^3.23.8" }, "devDependencies": { - "@faker-js/faker": "^9.0.3", + "@faker-js/faker": "^9.2.0", "@istanbuljs/nyc-config-typescript": "^1.0.2", - "@nestjs/cli": "^10.4.5", - "@nestjs/schematics": "^10.1.4", - "@nestjs/testing": "^10.4.4", + "@nestjs/cli": "^10.4.7", + "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^10.4.7", "@swc-node/register": "^1.10.9", - "@swc/cli": "^0.4.0", - "@swc/core": "^1.7.26", + "@swc/cli": "^0.5.0", + "@swc/core": "^1.9.2", "@types/cache-manager": "^4.0.6", "@types/chai": "^4.3.20", "@types/chai-as-promised": "^7.1.8", "@types/express": "^5.0.0", "@types/graphql-upload": "8.0.12", - "@types/mocha": "^10.0.8", - "@types/node": "^20.16.10", + "@types/mocha": "^10.0.9", + "@types/node": "^20.17.6", "@types/nodemailer": "^6.4.16", "@types/passport-jwt": "^4.0.1", "@types/proxyquire": "^1.3.31", "@types/sinon": "^17.0.3", "chai": "^4.5.0", "chai-as-promised": "^7.1.2", - "mocha": "^10.7.3", + "mocha": "^10.8.2", "nyc": "^17.1.0", - "prisma": "^5.20.0", + "prisma": "^5.22.0", "prisma-nestjs-graphql": "^20.0.3", "proxyquire": "^2.1.3", "sinon": "^19.0.2", @@ -114,7 +114,7 @@ "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "5.5.4" + "typescript": "5.6.3" }, "prisma": { "seed": "swc-node prisma/seed.ts" 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/ContestProblemDropdown.tsx b/apps/frontend/app/(client)/(code-editor)/_components/ContestProblemDropdown.tsx new file mode 100644 index 0000000000..515840edbd --- /dev/null +++ b/apps/frontend/app/(client)/(code-editor)/_components/ContestProblemDropdown.tsx @@ -0,0 +1,67 @@ +'use client' + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/shadcn/dropdown-menu' +import { cn, convertToLetter, isHttpError } from '@/libs/utils' +import checkIcon from '@/public/icons/check-green.svg' +import type { ProblemDetail } from '@/types/type' +import { useQuery } from '@tanstack/react-query' +import Image from 'next/image' +import Link from 'next/link' +import { FaSortDown } from 'react-icons/fa' +import { contestProblemQueries } from '../../_libs/queries/contestProblem' + +interface ContestProblemDropdownProps { + problem: Required + contestId: number +} + +export default function ContestProblemDropdown({ + problem, + contestId +}: ContestProblemDropdownProps) { + const { data: contestProblems, error } = useQuery({ + ...contestProblemQueries.list({ contestId, take: 20 }), + throwOnError: false + }) + + return ( + + +

{`${convertToLetter(problem.order)}. ${problem.title}`}

+ +
+ + {error && isHttpError(error) + ? 'Failed to load the contest problem' + : contestProblems?.data.map((p) => ( + + + {`${convertToLetter(p.order)}. ${p.title}`} + {p.submissionTime && ( +
+ check +
+ )} +
+ + ))} +
+
+ ) +} 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 50% rename from apps/frontend/components/EditorDescription.tsx rename to apps/frontend/app/(client)/(code-editor)/_components/EditorDescription.tsx index b13056c3b2..e2979060c7 100644 --- a/apps/frontend/components/EditorDescription.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorDescription.tsx @@ -6,79 +6,43 @@ 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' -import type { ContestProblem, ProblemDetail } from '@/types/type' -import type { Level } from '@/types/type' -import { motion } from 'framer-motion' +} from '@/components/shadcn/dialog' +import { convertToLetter } from '@/libs/utils' +import compileIcon from '@/public/icons/compile-version.svg' +import type { ProblemDetail } from '@/types/type' 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 { - 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 } -} +import CopyButton from './CopyButton' +import { WhitespaceVisualizer } from './WhitespaceVisualizer' export function EditorDescription({ problem, - contestProblems, isContest = false }: { problem: ProblemDetail - contestProblems?: ContestProblem[] isContest?: boolean }) { - const { copiedID, copy } = useCopy() - const level = problem.difficulty const levelNumber = level.slice(-1) + return (
-

{`#${contestProblems ? convertToLetter(contestProblems.find((item) => item.id === problem.id)?.order as number) : problem.id}. ${problem.title}`}

+

{`#${problem?.order !== undefined ? convertToLetter(problem.order) : problem.id}. ${problem.title}`}

{!isContest && ( {`Level ${levelNumber}`} @@ -92,39 +56,23 @@ export function EditorDescription({

Input

-
+
+ +

Output

-
+
+ +

{problem.problemTestcase.map(({ id, input, output }, index) => { - const whitespaceStyle = - 'color: rgb(53, 129, 250); min-width: 0.5em; display: inline-block;' - const changedInput = input - .replaceAll(/ /g, ``) - .replaceAll(/\n/g, `\n`) - .replaceAll(/\t/g, ``) - const changedOutput = output - .replaceAll(/ /g, ``) - .replaceAll(/\n/g, `\n`) - .replaceAll(/\t/g, ``) return (

Sample

@@ -135,61 +83,10 @@ 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

-
-
-
+
-
+                    
                   
@@ -198,61 +95,10 @@ 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

-
-
-
+
-
+                    
                   
@@ -286,7 +132,7 @@ export function EditorDescription({
           
         
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/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx
new file mode 100644
index 0000000000..76b6da54ed
--- /dev/null
+++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorHeader/EditorHeader.tsx
@@ -0,0 +1,398 @@
+'use client'
+
+import { contestProblemQueries } from '@/app/(client)/_libs/queries/contestProblem'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+  AlertDialogTrigger
+} from '@/components/shadcn/alert-dialog'
+import { Button } from '@/components/shadcn/button'
+import {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectTrigger,
+  SelectValue
+} 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,
+  useCodeStore,
+  getStorageKey,
+  getCodeFromLocalStorage
+} from '@/stores/editor'
+import type {
+  Language,
+  ProblemDetail,
+  Submission,
+  Template
+} from '@/types/type'
+import { useQueryClient } from '@tanstack/react-query'
+import JSConfetti from 'js-confetti'
+import { Save } from 'lucide-react'
+import type { Route } from 'next'
+import Image from 'next/image'
+import { usePathname, useRouter } from 'next/navigation'
+import { useEffect, useRef, useState } from 'react'
+import { BsTrash3 } from 'react-icons/bs'
+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
+}
+
+export default function Editor({
+  problem,
+  contestId,
+  templateString
+}: ProblemEditorProps) {
+  const { language, setLanguage } = useLanguageStore(problem.id, contestId)()
+  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('')
+  const [userName, setUserName] = useState('')
+  const router = useRouter()
+  const pathname = usePathname()
+  const confetti = typeof window !== 'undefined' ? new JSConfetti() : null
+  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)
+
+  const queryClient = useQueryClient()
+
+  useInterval(
+    async () => {
+      const res = await fetcherWithAuth(`submission/${submissionId}`, {
+        searchParams: {
+          problemId: problem.id,
+          ...(contestId && { contestId })
+        }
+      })
+      if (res.ok) {
+        const submission: Submission = await res.json()
+        if (submission.result !== 'Judging') {
+          setIsSubmitting(false)
+          const href = contestId
+            ? `/contest/${contestId}/problem/${problem.id}/submission/${submissionId}`
+            : `/problem/${problem.id}/submission/${submissionId}`
+          router.replace(href as Route)
+          //window.history.pushState(null, '', window.location.href)
+          if (submission.result === 'Accepted') {
+            confetti?.addConfetti()
+          }
+        }
+      } else {
+        setIsSubmitting(false)
+        toast.error('Please try again later.')
+      }
+    },
+    loading && submissionId ? 500 : null
+  )
+
+  useEffect(() => {
+    auth().then((session) => {
+      if (!session) {
+        toast.info('Log in to use submission & save feature')
+      } else {
+        setUserName(session.user.username)
+      }
+    })
+  }, [currentModal])
+
+  useEffect(() => {
+    if (!templateString) return
+    const parsedTemplates = JSON.parse(templateString)
+    const filteredTemplate = parsedTemplates.filter(
+      (template: Template) => template.language === language
+    )
+    if (filteredTemplate.length === 0) return
+    setTemplateCode(filteredTemplate[0].code[0].text)
+  }, [language])
+
+  useEffect(() => {
+    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)
+    setIsSubmitting(true)
+    const res = await fetcherWithAuth.post('submission', {
+      json: {
+        language,
+        code: [
+          {
+            id: 1,
+            text: code,
+            locked: false
+          }
+        ]
+      },
+      searchParams: {
+        problemId: problem.id,
+        ...(contestId && { contestId })
+      },
+      next: {
+        revalidate: 0
+      }
+    })
+    if (res.ok) {
+      toast.success('Successfully submitted the code')
+      storeCodeToLocalStorage(code)
+      const submission: Submission = await res.json()
+      setSubmissionId(submission.id)
+      if (contestId) {
+        queryClient.invalidateQueries({
+          queryKey: contestProblemQueries.lists(contestId)
+        })
+      }
+    } else {
+      setIsSubmitting(false)
+      if (res.status === 401) {
+        showSignIn()
+        toast.error('Log in first to submit your code')
+      } else toast.error('Please try again later.')
+    }
+  }
+
+  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 {
+      toast.error('Failed to save the code')
+    }
+  }
+
+  const resetCode = () => {
+    if (storageKey.current !== undefined) {
+      localStorage.setItem(storageKey.current, templateCode)
+      setCode(templateCode)
+      toast.success('Successfully reset the code')
+    } else toast.error('Failed to reset the code')
+  }
+
+  const checkSaved = () => {
+    const code = getCode()
+    if (storageKey.current !== undefined) {
+      const storedCode = getCodeFromLocalStorage(storageKey.current)
+      if (storedCode && storedCode === code) return true
+      else if (!storedCode && templateCode === code) return true
+      else return false
+    }
+    return true
+  }
+
+  const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+    if (!checkSaved()) {
+      e.preventDefault()
+      whereToPush.current = pathname
+    }
+  }
+
+  useEffect(() => {
+    storageKey.current = getStorageKey(
+      language,
+      problem.id,
+      userName,
+      contestId
+    )
+
+    // TODO: 배포 후 뒤로 가기 로직 재구현
+
+    // const handlePopState = () => {
+    //   if (!checkSaved()) {
+    //     whereToPush.current = contestId
+    //       ? `/contest/${contestId}/problem`
+    //       : '/problem'
+    //     setShowModal(true)
+    //   } else window.history.back()
+    // }
+    // if (!pushed.current) {
+    //   window.history.pushState(null, '', window.location.href)
+    //   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 (
+    
+
+ + + + + + + + Reset code + + + Are you sure you want to reset to the default code? + + + + Cancel + + Reset + + + + +
+
+ + + + +
+ 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/app/(client)/(code-editor)/_components/EditorLayout.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorLayout.tsx new file mode 100644 index 0000000000..3cebdb21e0 --- /dev/null +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorLayout.tsx @@ -0,0 +1,94 @@ +import ContestStatusTimeDiff from '@/components/ContestStatusTimeDiff' +import HeaderAuthPanel from '@/components/auth/HeaderAuthPanel' +import { auth } from '@/libs/auth' +import { fetcher, fetcherWithAuth } from '@/libs/utils' +import codedangLogo from '@/public/logos/codedang-editor.svg' +import type { Contest, ProblemDetail } from '@/types/type' +import Image from 'next/image' +import Link from 'next/link' +import { redirect } from 'next/navigation' +import type { GetContestProblemDetailResponse } from '../../_libs/apis/contestProblem' +import ContestProblemDropdown from './ContestProblemDropdown' +import EditorMainResizablePanel from './EditorResizablePanel' + +interface EditorLayoutProps { + contestId?: number + problemId: number + children: React.ReactNode +} + +export default async function EditorLayout({ + contestId, + problemId, + children +}: EditorLayoutProps) { + let contest: Contest | undefined + let problem: Required + + if (contestId) { + // for getting contest info and problems list + + // TODO: use `getContestProblemDetail` from _libs/apis folder & use error boundary + const res = await fetcherWithAuth( + `contest/${contestId}/problem/${problemId}` + ) + if (!res.ok && res.status === 403) { + redirect(`/contest/${contestId}/finished/problem/${problemId}`) + } + + const contestProblem = await res.json() + problem = { ...contestProblem.problem, order: contestProblem.order } + + contest = await fetcher(`contest/${contestId}`).json() + contest ? (contest.status = 'ongoing') : null // TODO: refactor this after change status interactively + } else { + problem = await fetcher(`problem/${problemId}`).json() + } + + const session = await auth() + + return ( +
+
+
+ + 코드당 + +
+ {contest ? <>Contest : Problem} +

/

+ {contest ? ( + <> + {contest.title} +

/

+ + + ) : ( +

{`#${problem.id}. ${problem.title}`}

+ )} +
+
+
+ {contest ? ( + + ) : null} + +
+
+ + {children} + +
+ ) +} diff --git a/apps/frontend/components/EditorResizablePanel.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorResizablePanel.tsx similarity index 54% rename from apps/frontend/components/EditorResizablePanel.tsx rename to apps/frontend/app/(client)/(code-editor)/_components/EditorResizablePanel.tsx index 50136877ec..3a3b0344ee 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 } from '@/components/ui/scroll-area' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { CodeContext, createCodeStore, useLanguageStore } from '@/stores/editor' -import type { Language, ProblemDetail, Template } 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, useContext, useEffect } from 'react' -import { useStore } from 'zustand' -import Loading from '../app/problem/[problemId]/loading' -import EditorHeader from './EditorHeader' +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 @@ -33,13 +35,14 @@ export default function EditorMainResizablePanel({ }: ProblemEditorProps) { const pathname = usePathname() const base = contestId ? `/contest/${contestId}` : '' - const { language, setLanguage } = useLanguageStore() - const store = createCodeStore(language, problem.id, contestId) + const { language, setLanguage } = useLanguageStore(problem.id, contestId)() + useEffect(() => { if (!problem.languages.includes(language)) { setLanguage(problem.languages[0]) } }, [problem.languages, language, setLanguage]) + return ( - +
- - - - + + + + + + + + + + + + + + + + + +
@@ -109,34 +138,22 @@ export default function EditorMainResizablePanel({ } interface CodeEditorInEditorResizablePanelProps { - templateString: string + problemId: number + contestId?: number enableCopyPaste: boolean } function CodeEditorInEditorResizablePanel({ - templateString, + problemId, + contestId, enableCopyPaste }: CodeEditorInEditorResizablePanelProps) { - const { language } = useLanguageStore() - const store = useContext(CodeContext) - if (!store) throw new Error('CodeContext is not provided') - const { code, setCode } = useStore(store) - - useEffect(() => { - if (!templateString) return - const parsedTemplates = JSON.parse(templateString) - const filteredTemplate = parsedTemplates.filter( - (template: Template) => template.language === language - ) - if (!code) { - if (filteredTemplate.length === 0) return - setCode(filteredTemplate[0].code[0].text) - } - }, [language]) + const { language } = useLanguageStore(problemId, contestId)() + const { code, setCode } = useCodeStore() return ( state.sampleTestcases) + + const { + formState, + testcases, + onSubmit, + register, + reset, + addTestcase, + removeTestcase + } = useUserTestcasesForm({ + onSubmit: () => setOpen(false) + }) + + const onOpenChange = (open: boolean) => { + if (!open) { + reset() + } + setOpen(open) + } + + 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')} +

+
+