From 4c7b77963fcba18d2699e64f74c4293ada61cf84 Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Mon, 17 Aug 2026 17:03:35 +0900 Subject: [PATCH 01/15] feat(be): add unregister API during ongoing contest --- .../admin/src/contest/contest.resolver.ts | 16 +++ .../apps/admin/src/contest/contest.service.ts | 111 ++++++++++++++++++ .../src/submission/submission-sub.service.ts | 1 + 3 files changed, 128 insertions(+) diff --git a/apps/backend/apps/admin/src/contest/contest.resolver.ts b/apps/backend/apps/admin/src/contest/contest.resolver.ts index bfd78e510e..4dfb43e2f8 100644 --- a/apps/backend/apps/admin/src/contest/contest.resolver.ts +++ b/apps/backend/apps/admin/src/contest/contest.resolver.ts @@ -118,6 +118,22 @@ export class ContestResolver { ) } + @Mutation(() => UserContest) + @UseDisableContestRolesGuard() + async removeUserDuringContest( + @Args('contestId', { type: () => Int }, IDValidationPipe) + contestId: number, + @Args('userId', { type: () => Int }, IDValidationPipe) + userId: number, + @Context('req') req: AuthenticatedRequest + ) { + return await this.contestService.removeUserDuringContest( + contestId, + userId, + req.user.id + ) + } + @Query(() => ContestSubmissionSummaryForUser) async getContestSubmissionSummaryByUserId( @Args('contestId', { type: () => Int }, IDValidationPipe) contestId: number, diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index cda1434aee..257c760f41 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.ts @@ -694,6 +694,117 @@ export class ContestService { }) } + /** + * 특정 Contest의 Contest Admin / Manager가 진행중인 대회에서 User를 강퇴시킵니다. + * @param contestId 대회 ID + * @param userId 사용자 ID + * @param reqId Contest Admin / Manager ID + * @throws {EntityNotExistException} 해당 contestId를 가지는 Contest가 존재하지 않을 경우 + * @throws {EntityNotExistException} 해당 Contest에 참여하고 있지 않은 userId인 경우 + * @throws {ForbiddenAccessException} ContestAdmin 또는 ContestManager가 아닌 reqId인 경우 + * @throws {ForbiddenAccessException} 시작 전이거나 종료된 Contest인 경우 + * @returns + */ + async removeUserDuringContest( + contestId: number, + userId: number, + reqId: number + ) { + const [contest, contestRecord, requesterRole] = await Promise.all([ + this.prisma.contest.findUnique({ + where: { id: contestId }, + select: { startTime: true, endTime: true } + }), + this.prisma.contestRecord.findFirst({ + where: { userId, contestId }, + select: { id: true } + }), + this.prisma.userContest.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_contestId: { + userId: reqId, + contestId + } + }, + select: { role: true } + }) + ]) + + if (!contest) { + throw new EntityNotExistException('Contest') + } + if (!contestRecord) { + throw new EntityNotExistException('ContestRecord') + } + if ( + !requesterRole || + (requesterRole.role !== ContestRole.Admin && + requesterRole.role !== ContestRole.Manager) + ) { + throw new ForbiddenAccessException( + 'Only Admin or Manager can remove users from contest' + ) + } + + const now = new Date() + if (now < contest.startTime || now > contest.endTime) { + throw new ForbiddenAccessException( + 'Cannot unregister not started or ended contest' + ) + } + + return await this.prisma.$transaction(async (tx) => { + // 강퇴당한 user가 firstSolver인 문제 존재 여부 확인 후 있으면 다음 사람에게 이월 + const firstSolveProblems = await tx.contestProblemFirstSolver.findMany({ + where: { contestRecordId: contestRecord.id }, + select: { contestProblemId: true } + }) + + if (firstSolveProblems.length !== 0) { + for (const { contestProblemId } of firstSolveProblems) { + const nextSolver = await tx.contestProblemRecord.findFirst({ + where: { + contestProblemId, + finishTime: { not: null }, + contestRecordId: { not: contestRecord.id } + }, + orderBy: { finishTime: 'asc' } + }) + + if (nextSolver) { + await tx.contestProblemFirstSolver.update({ + where: { contestProblemId }, + data: { contestRecordId: nextSolver.contestRecordId } + }) + await tx.contestProblemRecord.update({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestProblemId_contestRecordId: { + contestProblemId, + contestRecordId: nextSolver.contestRecordId + } + }, + data: { isFirstSolver: true } + }) + } + } + } + + await tx.contestRecord.delete({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestId_userId: { contestId, userId } + } + }) + + return tx.userContest.delete({ + // eslint-disable-next-line @typescript-eslint/naming-convention + where: { userId_contestId: { userId, contestId } } + }) + }) + } + /** * 특정 사용자의 대회 제출 목록과 점수 요약을 조회합니다. * 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 ef0b78b89d..7e6418dfee 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -619,6 +619,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { finalScore: score, finalTimePenalty: timePenalty, finalSubmitCountPenalty: submitCountPenalty, + finishTime: updateTime, ...(!isFreezed ? { score, submitCountPenalty, timePenalty } : {}) } From c77dd42cff023c2d51e045210f782c62c6961c3e Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Sun, 23 Aug 2026 15:58:37 +0900 Subject: [PATCH 02/15] fix(be): fix blockUserDuringContest logic and exclude blocked users from leaderboard --- .../admin/src/contest/contest.resolver.ts | 4 +- .../apps/admin/src/contest/contest.service.ts | 142 +++++++++++------- .../client/src/contest/contest.service.ts | 9 +- 3 files changed, 96 insertions(+), 59 deletions(-) diff --git a/apps/backend/apps/admin/src/contest/contest.resolver.ts b/apps/backend/apps/admin/src/contest/contest.resolver.ts index 4dfb43e2f8..82372a5f1c 100644 --- a/apps/backend/apps/admin/src/contest/contest.resolver.ts +++ b/apps/backend/apps/admin/src/contest/contest.resolver.ts @@ -120,14 +120,14 @@ export class ContestResolver { @Mutation(() => UserContest) @UseDisableContestRolesGuard() - async removeUserDuringContest( + async blockUserDuringContest( @Args('contestId', { type: () => Int }, IDValidationPipe) contestId: number, @Args('userId', { type: () => Int }, IDValidationPipe) userId: number, @Context('req') req: AuthenticatedRequest ) { - return await this.contestService.removeUserDuringContest( + return await this.contestService.blockUserDuringContest( contestId, userId, req.user.id diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index 257c760f41..21afaa80fe 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.ts @@ -695,7 +695,7 @@ export class ContestService { } /** - * 특정 Contest의 Contest Admin / Manager가 진행중인 대회에서 User를 강퇴시킵니다. + * 특정 Contest의 Contest Admin / Manager가 진행중인 대회에서 User를 차단시킵니다. * @param contestId 대회 ID * @param userId 사용자 ID * @param reqId Contest Admin / Manager ID @@ -703,33 +703,45 @@ export class ContestService { * @throws {EntityNotExistException} 해당 Contest에 참여하고 있지 않은 userId인 경우 * @throws {ForbiddenAccessException} ContestAdmin 또는 ContestManager가 아닌 reqId인 경우 * @throws {ForbiddenAccessException} 시작 전이거나 종료된 Contest인 경우 + * @throws {ForbiddenAccessException} 이미 차단된 유저인 경우 * @returns */ - async removeUserDuringContest( + async blockUserDuringContest( contestId: number, userId: number, reqId: number ) { - const [contest, contestRecord, requesterRole] = await Promise.all([ - this.prisma.contest.findUnique({ - where: { id: contestId }, - select: { startTime: true, endTime: true } - }), - this.prisma.contestRecord.findFirst({ - where: { userId, contestId }, - select: { id: true } - }), - this.prisma.userContest.findUnique({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - userId_contestId: { - userId: reqId, - contestId - } - }, - select: { role: true } - }) - ]) + const [contest, contestRecord, userContest, requesterRole] = + await Promise.all([ + this.prisma.contest.findUnique({ + where: { id: contestId }, + select: { startTime: true, endTime: true } + }), + this.prisma.contestRecord.findFirst({ + where: { userId, contestId }, + select: { id: true } + }), + this.prisma.userContest.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_contestId: { + userId: userId, + contestId + } + }, + select: { id: true, isBlocked: true } + }), + this.prisma.userContest.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_contestId: { + userId: reqId, + contestId + } + }, + select: { role: true } + }) + ]) if (!contest) { throw new EntityNotExistException('Contest') @@ -737,6 +749,7 @@ export class ContestService { if (!contestRecord) { throw new EntityNotExistException('ContestRecord') } + if ( !requesterRole || (requesterRole.role !== ContestRole.Admin && @@ -754,6 +767,10 @@ export class ContestService { ) } + if (userContest?.isBlocked) { + throw new ForbiddenAccessException('That user is already blocked') + } + return await this.prisma.$transaction(async (tx) => { // 강퇴당한 user가 firstSolver인 문제 존재 여부 확인 후 있으면 다음 사람에게 이월 const firstSolveProblems = await tx.contestProblemFirstSolver.findMany({ @@ -761,46 +778,52 @@ export class ContestService { select: { contestProblemId: true } }) - if (firstSolveProblems.length !== 0) { - for (const { contestProblemId } of firstSolveProblems) { - const nextSolver = await tx.contestProblemRecord.findFirst({ - where: { + for (const { contestProblemId } of firstSolveProblems) { + await tx.contestProblemRecord.update({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestProblemId_contestRecordId: { contestProblemId, - finishTime: { not: null }, - contestRecordId: { not: contestRecord.id } + contestRecordId: contestRecord.id + } + }, + data: { isFirstSolver: false } + }) + const nextSolver = await tx.contestProblemRecord.findFirst({ + where: { + contestProblemId, + finishTime: { not: null }, + contestRecordId: { not: contestRecord.id } + }, + orderBy: { finishTime: 'asc' } + }) + + if (nextSolver) { + await tx.contestProblemFirstSolver.update({ + where: { contestProblemId }, + data: { contestRecordId: nextSolver.contestRecordId } + }) + await tx.contestProblemRecord.update({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestProblemId_contestRecordId: { + contestProblemId, + contestRecordId: nextSolver.contestRecordId + } }, - orderBy: { finishTime: 'asc' } + data: { isFirstSolver: true } + }) + } else { + await tx.contestProblemFirstSolver.delete({ + where: { contestProblemId } }) - - if (nextSolver) { - await tx.contestProblemFirstSolver.update({ - where: { contestProblemId }, - data: { contestRecordId: nextSolver.contestRecordId } - }) - await tx.contestProblemRecord.update({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - contestProblemId_contestRecordId: { - contestProblemId, - contestRecordId: nextSolver.contestRecordId - } - }, - data: { isFirstSolver: true } - }) - } } } - await tx.contestRecord.delete({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - contestId_userId: { contestId, userId } - } - }) - - return tx.userContest.delete({ + return tx.userContest.update({ // eslint-disable-next-line @typescript-eslint/naming-convention - where: { userId_contestId: { userId, contestId } } + where: { userId_contestId: { userId, contestId } }, + data: { isBlocked: true } }) }) } @@ -1267,7 +1290,14 @@ export class ContestService { }), // 항상 finalScore, finalTotalPenalty 사용 this.prisma.contestRecord.findMany({ - where: { contestId }, + where: { + contestId, + user: { + userContest: { + none: { contestId, isBlocked: true } + } + } + }, select: { userId: true, user: { select: { username: true } }, diff --git a/apps/backend/apps/client/src/contest/contest.service.ts b/apps/backend/apps/client/src/contest/contest.service.ts index 77352286b3..08481f0770 100644 --- a/apps/backend/apps/client/src/contest/contest.service.ts +++ b/apps/backend/apps/client/src/contest/contest.service.ts @@ -408,7 +408,14 @@ export class ContestService { const maxScore = sum._sum?.score ?? 0 const contestRecordsPromise = this.prisma.contestRecord.findMany({ - where: { contestId }, + where: { + contestId, + user: { + userContest: { + none: { contestId, isBlocked: true } + } + } + }, select: { userId: true, user: { From 2e219c7f34143a5a1845b7a4348e4ddf48c02bad Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Sun, 23 Aug 2026 16:02:35 +0900 Subject: [PATCH 03/15] fix(be): reject submissions from blocked users --- .../src/submission/submission.service.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index e47e7a12dd..dfccf520de 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -126,6 +126,8 @@ export class SubmissionService { * - 유효한 진행 중인 대회가 없을 경우 (Contest) * - 사용자가 대회에 등록되어 있지 않은 경우 (ContestRecord) * - 문제를 찾을 수 없거나 대회와 매칭되지 않는 경우 (ContestProblem) + * @throws {ForbiddenAccessException} 아래의 경우에 발생합니다 + * - 차단된 유저인 경우 * @throws {ConflictFoundException} 아래의 경우에 발생합니다 * - 대회가 진행중이지 않을 경우 */ @@ -177,9 +179,26 @@ export class SubmissionService { } } }) + + const userContest = await this.prisma.userContest.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_contestId: { + userId, + contestId + } + }, + select: { isBlocked: true } + }) + if (!contestRecord) { throw new EntityNotExistException('ContestRecord') } + if (userContest?.isBlocked) { + throw new ForbiddenAccessException( + 'Blocked users cannot submit to this contest' + ) + } if ( contestRecord.contest.startTime > now || contestRecord.contest.endTime <= now From 220c8101bfedf829719d7b3d440032e101763933 Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Mon, 24 Aug 2026 13:28:26 +0900 Subject: [PATCH 04/15] chore(be): add seed data --- apps/backend/prisma/seed.ts | 74 +++++++++++++++++++++++++++++++++++++ apps/backend/schema.gql | 1 + 2 files changed, 75 insertions(+) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 617c692545..3882ed0b35 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -5039,6 +5039,15 @@ const createContestRecords = async () => { contestRecords.push(updated) } + + // contest 20 (진행중, contestAdmin=Admin) blockUserDuringContest 테스트용 참가자 2명 + const blockTestUsers = [users[1], users[2]] // user02, user03 + for (const user of blockTestUsers) { + const record = await prisma.contestRecord.create({ + data: { contestId: 20, userId: user.id } + }) + contestRecords.push(record) + } } const createUserContests = async () => { @@ -5181,6 +5190,71 @@ const createContestProblemRecords = async () => { } } + // contest 20 blockUserDuringContest 테스트용 first-solver 데이터 + const contest20 = await prisma.contest.findUnique({ + where: { id: 20 }, + select: { + contestProblem: { + orderBy: { order: 'asc' }, + select: { id: true, score: true } + } + } + }) + + if (contest20?.contestProblem.length) { + const [sharedProblem, soloProblem] = contest20.contestProblem + const [firstSolverRecord, secondSolverRecord] = + await prisma.contestRecord.findMany({ + where: { contestId: 20 }, + orderBy: { userId: 'asc' } + }) + const baseTime = new Date('2026-01-01T00:00:00.000Z') + + await prisma.contestProblemRecord.create({ + data: { + contestProblemId: sharedProblem.id, + contestRecordId: firstSolverRecord.id, + score: sharedProblem.score, + finalScore: sharedProblem.score, + finishTime: baseTime, + isFirstSolver: true + } + }) + await prisma.contestProblemRecord.create({ + data: { + contestProblemId: sharedProblem.id, + contestRecordId: secondSolverRecord.id, + score: sharedProblem.score, + finalScore: sharedProblem.score, + finishTime: new Date(baseTime.getTime() + 10 * 60_000), + isFirstSolver: false + } + }) + await prisma.contestProblemFirstSolver.create({ + data: { + contestProblemId: sharedProblem.id, + contestRecordId: firstSolverRecord.id + } + }) + + await prisma.contestProblemRecord.create({ + data: { + contestProblemId: soloProblem.id, + contestRecordId: firstSolverRecord.id, + score: soloProblem.score, + finalScore: soloProblem.score, + finishTime: baseTime, + isFirstSolver: true + } + }) + await prisma.contestProblemFirstSolver.create({ + data: { + contestProblemId: soloProblem.id, + contestRecordId: firstSolverRecord.id + } + }) + } + return contestProblemRecords } diff --git a/apps/backend/schema.gql b/apps/backend/schema.gql index a51119897e..17bb7cbbf5 100644 --- a/apps/backend/schema.gql +++ b/apps/backend/schema.gql @@ -2338,6 +2338,7 @@ type Match { type Mutation { autoFinalizeScore(assignmentId: Int!, groupId: Int!): Float! + blockUserDuringContest(contestId: Int!, userId: Int!): UserContest! checkAssignmentSubmissions(assignmentId: Int!, groupId: Int!, input: CreatePlagiarismCheckInput!, problemId: Int!): CheckRequest! cloneCourseNotices(courseNoticeIds: [Int!]!, groupId: Int!): [CourseNotice!]! createAnnouncement(contestId: Int!, input: CreateAnnouncementInput!): Announcement! From afcecd2574340faca2258acb8e3f166f0dc6cc4e Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Mon, 24 Aug 2026 13:31:28 +0900 Subject: [PATCH 05/15] test(be): add bruno requests for blockUserDuringContest --- .../Block User During Contest/Succeed.bru | 96 +++++++++++++++++++ .../[ERR] Blocked User.bru | 48 ++++++++++ .../[ERR] Nonexistent Contest.bru | 48 ++++++++++ .../[ERR] Nonexistent ContestRecord.bru | 48 ++++++++++ .../[ERR] Not Contest Staff.bru | 54 +++++++++++ .../[ERR] Not Started Or Ended Contest.bru | 52 ++++++++++ .../Block User During Contest/folder.bru | 8 ++ 7 files changed, 354 insertions(+) create mode 100644 collection/admin/Contest/Block User During Contest/Succeed.bru create mode 100644 collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru create mode 100644 collection/admin/Contest/Block User During Contest/[ERR] Nonexistent Contest.bru create mode 100644 collection/admin/Contest/Block User During Contest/[ERR] Nonexistent ContestRecord.bru create mode 100644 collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru create mode 100644 collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru create mode 100644 collection/admin/Contest/Block User During Contest/folder.bru diff --git a/collection/admin/Contest/Block User During Contest/Succeed.bru b/collection/admin/Contest/Block User During Contest/Succeed.bru new file mode 100644 index 0000000000..51cf0ea789 --- /dev/null +++ b/collection/admin/Contest/Block User During Contest/Succeed.bru @@ -0,0 +1,96 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: inherit +} + +body:graphql { + mutation BlockUserDuringContest( + $contestId: Int!, + $userId: Int! + ) { + blockUserDuringContest( + contestId: $contestId, + userId: $userId + ) { + userId + contestId + role + } + } +} + +body:graphql:vars { + { + "contestId": 20, + "userId": 8 + } +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + # 📘 Block User During Contest + + **POST** `/graphql (mutation: blockUserDuringContest)` + + Blocks a user from a contest's registration list. + + 진행중인 대회 참가자 명단에서 특정 사용자를 차단합니다. + + 이 작업은 대회의 `Admin` 또는 `Manager`만 수행할 수 있으며, **대회 진행중에만** 가능합니다. + + 차단된 사용자의 대회 역할 정보가 반환됩니다. + + --- + + ### 🔒 Authentication + + ✅ Required (Contest Staff) + + --- + + ### 📥 Request Variables + + | Name | Type | Required | Description | + |---|---|---|---| + | `contestId` | Int | ✅ | 사용자를 차단할 대회의 ID | + | `userId` | Int | ✅ | 차단될 사용자의 ID | + + --- + + ### 📤 Response Body + + #### Content-Type: `application/json` + + ```json + { + "data": { + "blockUserDuringContest": { + "userId": 8, + "contestId": 20, + "role": "Participant" + } + } + } + ``` + + | Field | Type | Description | + |--------|------|------| + | `userId` | Int | 사용자 ID | + | `contestId` | Int | 대회 ID | + | `role` | String | 차단된 사용자 역할 | +} diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru b/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru new file mode 100644 index 0000000000..3e1784d413 --- /dev/null +++ b/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru @@ -0,0 +1,48 @@ +meta { + name: [ERR] Blocked User + type: graphql + seq: 6 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation BlockUserDuringContest( + $contestId: Int!, + $userId: Int! + ) { + blockUserDuringContest( + contestId: $contestId, + userId: $userId + ) { + userId + } + } +} + +body:graphql:vars { + { + "contestId": 20, + "userId": 8 + } +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +docs { + # 📙 [ERR] Blocked User + + **POST** `/graphql (mutation: blockUserDuringContest)` + + The user is already blocked. + + 차단하려는 대상(`userId`)이 이미 해당 대회에서 차단되었을 때, + + `ForbiddenAccessException`이 발생합니다. +} diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent Contest.bru b/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent Contest.bru new file mode 100644 index 0000000000..4dcbb85ba0 --- /dev/null +++ b/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent Contest.bru @@ -0,0 +1,48 @@ +meta { + name: [ERR] Nonexistent Contest + type: graphql + seq: 4 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation BlockUserDuringContest( + $contestId: Int!, + $userId: Int! + ) { + blockUserDuringContest( + contestId: $contestId, + userId: $userId + ) { + userId + } + } +} + +body:graphql:vars { + { + "contestId": 999, + "userId": 7 + } +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +docs { + # 📙 [ERR] Nonexistent Contest + + **POST** `/graphql (mutation: blockUserDuringContest)` + + The requested contest does not exist. + + 존재하지 않는 `contestId`를 요청했을 때, + + `EntityNotExistException`이 발생합니다. +} diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent ContestRecord.bru b/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent ContestRecord.bru new file mode 100644 index 0000000000..f19fce39ea --- /dev/null +++ b/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent ContestRecord.bru @@ -0,0 +1,48 @@ +meta { + name: [ERR] Nonexistent ContestRecord + type: graphql + seq: 5 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation BlockUserDuringContest( + $contestId: Int!, + $userId: Int! + ) { + blockUserDuringContest( + contestId: $contestId, + userId: $userId + ) { + userId + } + } +} + +body:graphql:vars { + { + "contestId": 20, + "userId": 999 + } +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +docs { + # 📙 [ERR] Nonexistent ContestRecord + + **POST** `/graphql (mutation: blockUserDuringContest)` + + The user is not registered for the contest. + + 차단하려는 대상(`userId`)이 해당 대회의 참가자 명단에 없을 때, + + `EntityNotExistException`이 발생합니다. +} diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru b/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru new file mode 100644 index 0000000000..8dcb607752 --- /dev/null +++ b/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru @@ -0,0 +1,54 @@ +meta { + name: [ERR] Not Contest Staff + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation BlockUserDuringContest( + $contestId: Int!, + $userId: Int! + ) { + blockUserDuringContest( + contestId: $contestId, + userId: $userId + ) { + userId + contestId + role + } + } +} + +body:graphql:vars { + { + "contestId": 1, + "userId": 7 + } +} + +assert { + res.body.errors: isDefined +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +docs { + [ERR] Not Contest Staff + + **POST** `/graphql (mutation: blockUserDuringContest)` + + Only Admin or Manager can remove users from contest. + + 요청자가 해당 대회의 `Admin` 또는 `Manager`가 아닐 때, + + `ForbiddenAccessException`이 발생합니다. +} diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru b/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru new file mode 100644 index 0000000000..f556e94995 --- /dev/null +++ b/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru @@ -0,0 +1,52 @@ +meta { + name: [ERR] Not Started Or Ended Contest + type: graphql + seq: 3 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation BlockUserDuringContest( + $contestId: Int!, + $userId: Int! + ) { + blockUserDuringContest( + contestId: $contestId, + userId: $userId + ) { + userId + } + } +} + +body:graphql:vars { + { + "contestId": 19, + "userId": 7 + } +} + +assert { + res.body.errors: isDefined +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +docs { + # 📙 [ERR] Not Started Or Ended Contest + + **POST** `/graphql (mutation: blockUserDuringContest)` + + Cannot block from not started or ended contest. + + 시작되기 전이나 종료된 대회에서 사용자를 차단하려고 할 때, + + `ForbiddenAccessException`이 발생합니다. +} diff --git a/collection/admin/Contest/Block User During Contest/folder.bru b/collection/admin/Contest/Block User During Contest/folder.bru new file mode 100644 index 0000000000..5b0b8c266f --- /dev/null +++ b/collection/admin/Contest/Block User During Contest/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Block User During Contest + seq: 19 +} + +auth { + mode: inherit +} From 7079598536b62a97cecef8820611178329f5b519 Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Mon, 24 Aug 2026 16:57:04 +0900 Subject: [PATCH 06/15] fix(be): exclude already-blocked users from firstSolver handoff --- .../apps/admin/src/contest/contest.service.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index 21afaa80fe..d8a7a139b6 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.ts @@ -725,7 +725,7 @@ export class ContestService { where: { // eslint-disable-next-line @typescript-eslint/naming-convention userId_contestId: { - userId: userId, + userId, contestId } }, @@ -756,14 +756,14 @@ export class ContestService { requesterRole.role !== ContestRole.Manager) ) { throw new ForbiddenAccessException( - 'Only Admin or Manager can remove users from contest' + 'Only Admin or Manager can block users from contest' ) } const now = new Date() if (now < contest.startTime || now > contest.endTime) { throw new ForbiddenAccessException( - 'Cannot unregister not started or ended contest' + 'Cannot block user not started or ended contest' ) } @@ -793,7 +793,14 @@ export class ContestService { where: { contestProblemId, finishTime: { not: null }, - contestRecordId: { not: contestRecord.id } + contestRecordId: { not: contestRecord.id }, + contestRecord: { + user: { + userContest: { + none: { contestId, isBlocked: true } + } + } + } }, orderBy: { finishTime: 'asc' } }) From a8f7738f5f34a82d319321783bedb23d68f4e2b1 Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Mon, 24 Aug 2026 17:03:50 +0900 Subject: [PATCH 07/15] chore(be): fetch contestRecord and userContest concurrently --- .../src/submission/submission.service.ts | 44 +++++++------------ .../test/submission.service.spec.ts | 3 +- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index dfccf520de..029d3c5095 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -162,34 +162,24 @@ export class SubmissionService { if (!isStaff) { // 대회에 등록되어 있는지 확인합니다. - const contestRecord = await this.prisma.contestRecord.findUnique({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - contestId_userId: { - contestId, - userId - } - }, - select: { - contest: { - select: { - startTime: true, - endTime: true - } - } - } - }) - - const userContest = await this.prisma.userContest.findUnique({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - userId_contestId: { - userId, - contestId + const [contestRecord, userContest] = await Promise.all([ + this.prisma.contestRecord.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestId_userId: { contestId, userId } + }, + select: { + contest: { select: { startTime: true, endTime: true } } } - }, - select: { isBlocked: true } - }) + }), + this.prisma.userContest.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_contestId: { userId, contestId } + }, + select: { isBlocked: true } + }) + ]) if (!contestRecord) { throw new EntityNotExistException('ContestRecord') diff --git a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts index 0e8be95993..0be390629f 100644 --- a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts @@ -75,7 +75,8 @@ const db = { update: stub() }, userContest: { - findFirst: stub() + findFirst: stub(), + findUnique: stub() }, assignmentRecord: { findUnique: stub(), From 2f45202bc2bc33bebde6aef4e92b7f6e072b3f5e Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Mon, 24 Aug 2026 17:06:12 +0900 Subject: [PATCH 08/15] test(be): add seed fixtures and bruno requests for blockUserDuringContest --- apps/backend/prisma/seed.ts | 17 ++++++++++++++++- .../Block User During Contest/Succeed.bru | 5 +++++ .../[ERR] Blocked User.bru | 2 +- .../[ERR] Not Contest Staff.bru | 2 +- .../[ERR] Not Started Or Ended Contest.bru | 2 +- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 3882ed0b35..9e713b255f 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -5048,6 +5048,18 @@ const createContestRecords = async () => { }) contestRecords.push(record) } + const alreadyBlockedUser = users[3] // user04 + await prisma.contestRecord.create({ + data: { contestId: 20, userId: alreadyBlockedUser.id } + }) + await prisma.userContest.create({ + data: { + userId: alreadyBlockedUser.id, + contestId: 20, + role: ContestRole.Participant, + isBlocked: true + } + }) } const createUserContests = async () => { @@ -5201,7 +5213,10 @@ const createContestProblemRecords = async () => { } }) - if (contest20?.contestProblem.length) { + if (!contest20 || contest20.contestProblem.length < 2) { + throw new Error('contest20 requires at least two contest problems') + } + { const [sharedProblem, soloProblem] = contest20.contestProblem const [firstSolverRecord, secondSolverRecord] = await prisma.contestRecord.findMany({ diff --git a/collection/admin/Contest/Block User During Contest/Succeed.bru b/collection/admin/Contest/Block User During Contest/Succeed.bru index 51cf0ea789..5c4430e8b7 100644 --- a/collection/admin/Contest/Block User During Contest/Succeed.bru +++ b/collection/admin/Contest/Block User During Contest/Succeed.bru @@ -22,6 +22,7 @@ body:graphql { userId contestId role + isBlocked } } } @@ -33,6 +34,10 @@ body:graphql:vars { } } +assert { + res.body.data.blockUserDuringContest.isBlocked: eq true +} + script:pre-request { await require("./login").loginContestAdmin(req); } diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru b/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru index 3e1784d413..c398a13041 100644 --- a/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru +++ b/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru @@ -27,7 +27,7 @@ body:graphql { body:graphql:vars { { "contestId": 20, - "userId": 8 + "userId": 10 } } diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru b/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru index 8dcb607752..1da5cc0074 100644 --- a/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru +++ b/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru @@ -34,7 +34,7 @@ body:graphql:vars { } assert { - res.body.errors: isDefined + res.body.errors[0].message: eq Only Admin or Manager can block users from contest } script:pre-request { diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru b/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru index f556e94995..e797870b98 100644 --- a/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru +++ b/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru @@ -32,7 +32,7 @@ body:graphql:vars { } assert { - res.body.errors: isDefined + res.body.errors[0].message: eq Cannot block user not started or ended contest } script:pre-request { From c1513f40efb60773beee7927b0c56ad32cf3991f Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Mon, 24 Aug 2026 17:55:10 +0900 Subject: [PATCH 09/15] chore(be): fix endTime, bruno docs --- apps/backend/apps/admin/src/contest/contest.service.ts | 2 +- collection/admin/Contest/Block User During Contest/Succeed.bru | 1 + .../Block User During Contest/[ERR] Not Contest Staff.bru | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index d8a7a139b6..36da0783ef 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.ts @@ -761,7 +761,7 @@ export class ContestService { } const now = new Date() - if (now < contest.startTime || now > contest.endTime) { + if (now < contest.startTime || now >= contest.endTime) { throw new ForbiddenAccessException( 'Cannot block user not started or ended contest' ) diff --git a/collection/admin/Contest/Block User During Contest/Succeed.bru b/collection/admin/Contest/Block User During Contest/Succeed.bru index 5c4430e8b7..9ce2badc92 100644 --- a/collection/admin/Contest/Block User During Contest/Succeed.bru +++ b/collection/admin/Contest/Block User During Contest/Succeed.bru @@ -88,6 +88,7 @@ docs { "userId": 8, "contestId": 20, "role": "Participant" + "isBlocked": true } } } diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru b/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru index 1da5cc0074..9ec42b3f7e 100644 --- a/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru +++ b/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru @@ -46,7 +46,7 @@ docs { **POST** `/graphql (mutation: blockUserDuringContest)` - Only Admin or Manager can remove users from contest. + Only Admin or Manager can block users from contest. 요청자가 해당 대회의 `Admin` 또는 `Manager`가 아닐 때, From 3e92f9048bed293f97400030f512eadeb84bc97a Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Wed, 26 Aug 2026 09:43:44 +0900 Subject: [PATCH 10/15] feat(be): implement unregister users during contest api --- .../admin/src/contest/contest.resolver.ts | 16 -- .../apps/admin/src/contest/contest.service.ts | 205 ++++++------------ 2 files changed, 67 insertions(+), 154 deletions(-) diff --git a/apps/backend/apps/admin/src/contest/contest.resolver.ts b/apps/backend/apps/admin/src/contest/contest.resolver.ts index 82372a5f1c..bfd78e510e 100644 --- a/apps/backend/apps/admin/src/contest/contest.resolver.ts +++ b/apps/backend/apps/admin/src/contest/contest.resolver.ts @@ -118,22 +118,6 @@ export class ContestResolver { ) } - @Mutation(() => UserContest) - @UseDisableContestRolesGuard() - async blockUserDuringContest( - @Args('contestId', { type: () => Int }, IDValidationPipe) - contestId: number, - @Args('userId', { type: () => Int }, IDValidationPipe) - userId: number, - @Context('req') req: AuthenticatedRequest - ) { - return await this.contestService.blockUserDuringContest( - contestId, - userId, - req.user.id - ) - } - @Query(() => ContestSubmissionSummaryForUser) async getContestSubmissionSummaryByUserId( @Args('contestId', { type: () => Int }, IDValidationPipe) contestId: number, diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index 36da0783ef..50f7e98b5b 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.ts @@ -626,8 +626,9 @@ export class ContestService { * @param reqId Contest Admin / Manager ID * @throws {EntityNotExistException} 해당 contestId를 가지는 Contest가 존재하지 않을 경우 * @throws {EntityNotExistException} 해당 Contest에 참여하고 있지 않은 userId인 경우 + * @throws {EntityNotExistException} 해당 Contest에 기록이 존재하지 않는 userId인 경우 * @throws {ForbiddenAccessException} ContestAdmin 또는 ContestManager가 아닌 reqId인 경우 - * @throws {ForbiddenAccessException} 진행 중이거나 종료된 Contest인 경우 + * @throws {ForbiddenAccessException} 종료된 Contest인 경우 * @returns */ async removeUserFromContest( @@ -635,92 +636,12 @@ export class ContestService { userId: number, reqId: number ) { - const [contest, contestRecord, requesterRole] = await Promise.all([ - this.prisma.contest.findUnique({ - where: { id: contestId }, - select: { startTime: true } - }), - this.prisma.contestRecord.findFirst({ - where: { userId, contestId }, - select: { id: true } - }), - this.prisma.userContest.findUnique({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - userId_contestId: { - userId: reqId, - contestId - } - }, - select: { role: true } - }) - ]) - - if (!contest) { - throw new EntityNotExistException('Contest') - } - if (!contestRecord) { - throw new EntityNotExistException('ContestRecord') - } - if ( - !requesterRole || - (requesterRole.role !== ContestRole.Admin && - requesterRole.role !== ContestRole.Manager) - ) { - throw new ForbiddenAccessException( - 'Only Admin or Manager can remove users from contest' - ) - } - - const now = new Date() - if (now >= contest.startTime) { - throw new ForbiddenAccessException( - 'Cannot unregister ongoing or ended contest' - ) - } - - return await this.prisma.$transaction(async (tx) => { - await tx.contestRecord.delete({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - contestId_userId: { contestId, userId } - } - }) - - return tx.userContest.delete({ - // eslint-disable-next-line @typescript-eslint/naming-convention - where: { userId_contestId: { userId, contestId } } - }) - }) - } - - /** - * 특정 Contest의 Contest Admin / Manager가 진행중인 대회에서 User를 차단시킵니다. - * @param contestId 대회 ID - * @param userId 사용자 ID - * @param reqId Contest Admin / Manager ID - * @throws {EntityNotExistException} 해당 contestId를 가지는 Contest가 존재하지 않을 경우 - * @throws {EntityNotExistException} 해당 Contest에 참여하고 있지 않은 userId인 경우 - * @throws {ForbiddenAccessException} ContestAdmin 또는 ContestManager가 아닌 reqId인 경우 - * @throws {ForbiddenAccessException} 시작 전이거나 종료된 Contest인 경우 - * @throws {ForbiddenAccessException} 이미 차단된 유저인 경우 - * @returns - */ - async blockUserDuringContest( - contestId: number, - userId: number, - reqId: number - ) { - const [contest, contestRecord, userContest, requesterRole] = + const [contest, userContest, contestRecord, requesterRole] = await Promise.all([ this.prisma.contest.findUnique({ where: { id: contestId }, select: { startTime: true, endTime: true } }), - this.prisma.contestRecord.findFirst({ - where: { userId, contestId }, - select: { id: true } - }), this.prisma.userContest.findUnique({ where: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -729,7 +650,11 @@ export class ContestService { contestId } }, - select: { id: true, isBlocked: true } + select: { id: true } + }), + this.prisma.contestRecord.findFirst({ + where: { userId, contestId }, + select: { id: true } }), this.prisma.userContest.findUnique({ where: { @@ -746,91 +671,95 @@ export class ContestService { if (!contest) { throw new EntityNotExistException('Contest') } + if (!userContest) { + throw new EntityNotExistException('UserContest') + } if (!contestRecord) { throw new EntityNotExistException('ContestRecord') } - if ( !requesterRole || (requesterRole.role !== ContestRole.Admin && requesterRole.role !== ContestRole.Manager) ) { throw new ForbiddenAccessException( - 'Only Admin or Manager can block users from contest' + 'Only Admin or Manager can remove users from contest' ) } const now = new Date() - if (now < contest.startTime || now >= contest.endTime) { - throw new ForbiddenAccessException( - 'Cannot block user not started or ended contest' - ) - } - - if (userContest?.isBlocked) { - throw new ForbiddenAccessException('That user is already blocked') + if (now >= contest.endTime) { + throw new ForbiddenAccessException('Cannot unregister ended contest') } return await this.prisma.$transaction(async (tx) => { - // 강퇴당한 user가 firstSolver인 문제 존재 여부 확인 후 있으면 다음 사람에게 이월 - const firstSolveProblems = await tx.contestProblemFirstSolver.findMany({ - where: { contestRecordId: contestRecord.id }, - select: { contestProblemId: true } - }) - - for (const { contestProblemId } of firstSolveProblems) { - await tx.contestProblemRecord.update({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - contestProblemId_contestRecordId: { - contestProblemId, - contestRecordId: contestRecord.id - } - }, - data: { isFirstSolver: false } - }) - const nextSolver = await tx.contestProblemRecord.findFirst({ - where: { - contestProblemId, - finishTime: { not: null }, - contestRecordId: { not: contestRecord.id }, - contestRecord: { - user: { - userContest: { - none: { contestId, isBlocked: true } - } - } - } - }, - orderBy: { finishTime: 'asc' } + if (now >= contest.startTime) { + // 강퇴당한 user가 firstSolver인 문제 존재 여부 확인 후 있으면 다음 사람에게 이월 + const firstSolveProblems = await tx.contestProblemFirstSolver.findMany({ + where: { contestRecordId: contestRecord.id }, + select: { contestProblemId: true } }) - if (nextSolver) { - await tx.contestProblemFirstSolver.update({ - where: { contestProblemId }, - data: { contestRecordId: nextSolver.contestRecordId } - }) + for (const { contestProblemId } of firstSolveProblems) { await tx.contestProblemRecord.update({ where: { // eslint-disable-next-line @typescript-eslint/naming-convention contestProblemId_contestRecordId: { contestProblemId, - contestRecordId: nextSolver.contestRecordId + contestRecordId: contestRecord.id } }, - data: { isFirstSolver: true } + data: { isFirstSolver: false } }) - } else { - await tx.contestProblemFirstSolver.delete({ - where: { contestProblemId } + const nextSolver = await tx.contestProblemRecord.findFirst({ + where: { + contestProblemId, + finishTime: { not: null }, + contestRecordId: { not: contestRecord.id }, + contestRecord: { + user: { + userContest: { + some: { contestId } + } + } + } + }, + orderBy: { finishTime: 'asc' } }) + + if (nextSolver) { + await tx.contestProblemFirstSolver.update({ + where: { contestProblemId }, + data: { contestRecordId: nextSolver.contestRecordId } + }) + await tx.contestProblemRecord.update({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestProblemId_contestRecordId: { + contestProblemId, + contestRecordId: nextSolver.contestRecordId + } + }, + data: { isFirstSolver: true } + }) + } else { + await tx.contestProblemFirstSolver.delete({ + where: { contestProblemId } + }) + } } + } else { + await tx.contestRecord.delete({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestId_userId: { contestId, userId } + } + }) } - return tx.userContest.update({ + return tx.userContest.delete({ // eslint-disable-next-line @typescript-eslint/naming-convention - where: { userId_contestId: { userId, contestId } }, - data: { isBlocked: true } + where: { userId_contestId: { userId, contestId } } }) }) } @@ -1301,7 +1230,7 @@ export class ContestService { contestId, user: { userContest: { - none: { contestId, isBlocked: true } + some: { contestId } } } }, From 592d2c54adf15997ab12d09cccfe7b7efb1c16be Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Wed, 26 Aug 2026 10:06:58 +0900 Subject: [PATCH 11/15] chore(be): exclude users without userContest from leaderboard and block submissions --- .../client/src/contest/contest.service.ts | 2 +- .../src/submission/submission.service.ts | 42 +++++-------------- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/apps/backend/apps/client/src/contest/contest.service.ts b/apps/backend/apps/client/src/contest/contest.service.ts index 08481f0770..2ef998abdd 100644 --- a/apps/backend/apps/client/src/contest/contest.service.ts +++ b/apps/backend/apps/client/src/contest/contest.service.ts @@ -412,7 +412,7 @@ export class ContestService { contestId, user: { userContest: { - none: { contestId, isBlocked: true } + some: { contestId } } } }, diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index 029d3c5095..e9c71d5af3 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -124,10 +124,8 @@ export class SubmissionService { * @returns {Promise} 생성된 제출 객체 * @throws {EntityNotExistException} 아래의 경우에 발생합니다 * - 유효한 진행 중인 대회가 없을 경우 (Contest) - * - 사용자가 대회에 등록되어 있지 않은 경우 (ContestRecord) + * - 사용자가 대회에 등록되어 있지 않은 경우 (UserContest) * - 문제를 찾을 수 없거나 대회와 매칭되지 않는 경우 (ContestProblem) - * @throws {ForbiddenAccessException} 아래의 경우에 발생합니다 - * - 차단된 유저인 경우 * @throws {ConflictFoundException} 아래의 경우에 발생합니다 * - 대회가 진행중이지 않을 경우 */ @@ -162,37 +160,17 @@ export class SubmissionService { if (!isStaff) { // 대회에 등록되어 있는지 확인합니다. - const [contestRecord, userContest] = await Promise.all([ - this.prisma.contestRecord.findUnique({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - contestId_userId: { contestId, userId } - }, - select: { - contest: { select: { startTime: true, endTime: true } } - } - }), - this.prisma.userContest.findUnique({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - userId_contestId: { userId, contestId } - }, - select: { isBlocked: true } - }) - ]) + const userContest = await this.prisma.userContest.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_contestId: { userId, contestId } + } + }) - if (!contestRecord) { - throw new EntityNotExistException('ContestRecord') + if (!userContest) { + throw new EntityNotExistException('UserContest') } - if (userContest?.isBlocked) { - throw new ForbiddenAccessException( - 'Blocked users cannot submit to this contest' - ) - } - if ( - contestRecord.contest.startTime > now || - contestRecord.contest.endTime <= now - ) { + if (contest.startTime > now || contest.endTime <= now) { throw new ConflictFoundException( 'Submission is only allowed to ongoing contests' ) From 6283636f35c80236540422b75432fe5859147b5f Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Wed, 26 Aug 2026 10:17:38 +0900 Subject: [PATCH 12/15] chore(be): update submitToContest spec for userContest restriction --- .../submission/test/submission.service.spec.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts index 0be390629f..66b7b37c99 100644 --- a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts @@ -250,14 +250,16 @@ describe('SubmissionService', () => { describe('submitToContest', () => { it('should call createSubmission', async () => { const createSpy = stub(service, 'createSubmission') - db.contest.findFirst.resolves(mockContest) - db.userContest.findFirst.resolves(null) - db.contestRecord.findUnique.resolves({ - contest: { - groupId: 1, - startTime: new Date(Date.now() - 10000), - endTime: new Date(Date.now() + 10000) - } + db.contest.findFirst.resolves({ + ...mockContest, + startTime: new Date(Date.now() - 10000), + endTime: new Date(Date.now() + 10000) + }) + db.userContest.findUnique.resolves({ + id: 1, + userId: submissions[0].userId, + contestId: CONTEST_ID, + role: 'participant' }) db.contestProblem.findUnique.resolves({ problem: problems[0] }) From 1187330cc6cfe12431b5f9d0c0d989e825e2395f Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Wed, 26 Aug 2026 11:33:36 +0900 Subject: [PATCH 13/15] test(be): update seed data and bruno requests --- apps/backend/prisma/seed.ts | 28 +++-- apps/backend/schema.gql | 1 - .../Block User During Contest/Succeed.bru | 102 ------------------ .../[ERR] Blocked User.bru | 48 --------- .../[ERR] Nonexistent Contest.bru | 48 --------- .../[ERR] Not Contest Staff.bru | 54 ---------- .../[ERR] Not Started Or Ended Contest.bru | 52 --------- .../Block User During Contest/folder.bru | 8 -- .../Remove User From Contest/Succeed.bru | 53 ++++----- ...ed Contest.bru => [ERR] Ended Contest.bru} | 22 ++-- .../[ERR] Nonexistent ContestRecord.bru | 18 ++-- .../[ERR] Nonexistent UserContest.bru} | 16 +-- .../[ERR] Not Contest Staff.bru | 18 ++-- .../Remove User From Contest/folder.bru | 4 + 14 files changed, 91 insertions(+), 381 deletions(-) delete mode 100644 collection/admin/Contest/Block User During Contest/Succeed.bru delete mode 100644 collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru delete mode 100644 collection/admin/Contest/Block User During Contest/[ERR] Nonexistent Contest.bru delete mode 100644 collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru delete mode 100644 collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru delete mode 100644 collection/admin/Contest/Block User During Contest/folder.bru rename collection/admin/Contest/Remove User From Contest/{[ERR] Started Contest.bru => [ERR] Ended Contest.bru} (64%) rename collection/admin/Contest/{Block User During Contest/[ERR] Nonexistent ContestRecord.bru => Remove User From Contest/[ERR] Nonexistent UserContest.bru} (54%) create mode 100644 collection/admin/Contest/Remove User From Contest/folder.bru diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 9e713b255f..b77e6cd0dd 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -5040,7 +5040,7 @@ const createContestRecords = async () => { contestRecords.push(updated) } - // contest 20 (진행중, contestAdmin=Admin) blockUserDuringContest 테스트용 참가자 2명 + // contest 20 (진행중, contestAdmin=Admin) removeUserFromContest 테스트용 참가자 3명 const blockTestUsers = [users[1], users[2]] // user02, user03 for (const user of blockTestUsers) { const record = await prisma.contestRecord.create({ @@ -5048,16 +5048,30 @@ const createContestRecords = async () => { }) contestRecords.push(record) } - const alreadyBlockedUser = users[3] // user04 + const alreadyRemovedUser = users[3] // user04 await prisma.contestRecord.create({ - data: { contestId: 20, userId: alreadyBlockedUser.id } + data: { contestId: 20, userId: alreadyRemovedUser.id } + }) + + // Ended Contest 테스트용 + const endedContest = await prisma.contest.findFirstOrThrow({ + where: { title: 'Long Time Ago Assignment' } + }) + await prisma.userContest.create({ + data: { + userId: contestAdminUser.id, + contestId: endedContest.id, + role: ContestRole.Admin + } + }) + await prisma.contestRecord.create({ + data: { contestId: endedContest.id, userId: contestReviewerUser.id } }) await prisma.userContest.create({ data: { - userId: alreadyBlockedUser.id, - contestId: 20, - role: ContestRole.Participant, - isBlocked: true + userId: contestReviewerUser.id, + contestId: endedContest.id, + role: ContestRole.Participant } }) } diff --git a/apps/backend/schema.gql b/apps/backend/schema.gql index 17bb7cbbf5..a51119897e 100644 --- a/apps/backend/schema.gql +++ b/apps/backend/schema.gql @@ -2338,7 +2338,6 @@ type Match { type Mutation { autoFinalizeScore(assignmentId: Int!, groupId: Int!): Float! - blockUserDuringContest(contestId: Int!, userId: Int!): UserContest! checkAssignmentSubmissions(assignmentId: Int!, groupId: Int!, input: CreatePlagiarismCheckInput!, problemId: Int!): CheckRequest! cloneCourseNotices(courseNoticeIds: [Int!]!, groupId: Int!): [CourseNotice!]! createAnnouncement(contestId: Int!, input: CreateAnnouncementInput!): Announcement! diff --git a/collection/admin/Contest/Block User During Contest/Succeed.bru b/collection/admin/Contest/Block User During Contest/Succeed.bru deleted file mode 100644 index 9ce2badc92..0000000000 --- a/collection/admin/Contest/Block User During Contest/Succeed.bru +++ /dev/null @@ -1,102 +0,0 @@ -meta { - name: Succeed - type: graphql - seq: 1 -} - -post { - url: {{gqlUrl}} - body: graphql - auth: inherit -} - -body:graphql { - mutation BlockUserDuringContest( - $contestId: Int!, - $userId: Int! - ) { - blockUserDuringContest( - contestId: $contestId, - userId: $userId - ) { - userId - contestId - role - isBlocked - } - } -} - -body:graphql:vars { - { - "contestId": 20, - "userId": 8 - } -} - -assert { - res.body.data.blockUserDuringContest.isBlocked: eq true -} - -script:pre-request { - await require("./login").loginContestAdmin(req); -} - -settings { - encodeUrl: true - timeout: 0 -} - -docs { - # 📘 Block User During Contest - - **POST** `/graphql (mutation: blockUserDuringContest)` - - Blocks a user from a contest's registration list. - - 진행중인 대회 참가자 명단에서 특정 사용자를 차단합니다. - - 이 작업은 대회의 `Admin` 또는 `Manager`만 수행할 수 있으며, **대회 진행중에만** 가능합니다. - - 차단된 사용자의 대회 역할 정보가 반환됩니다. - - --- - - ### 🔒 Authentication - - ✅ Required (Contest Staff) - - --- - - ### 📥 Request Variables - - | Name | Type | Required | Description | - |---|---|---|---| - | `contestId` | Int | ✅ | 사용자를 차단할 대회의 ID | - | `userId` | Int | ✅ | 차단될 사용자의 ID | - - --- - - ### 📤 Response Body - - #### Content-Type: `application/json` - - ```json - { - "data": { - "blockUserDuringContest": { - "userId": 8, - "contestId": 20, - "role": "Participant" - "isBlocked": true - } - } - } - ``` - - | Field | Type | Description | - |--------|------|------| - | `userId` | Int | 사용자 ID | - | `contestId` | Int | 대회 ID | - | `role` | String | 차단된 사용자 역할 | -} diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru b/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru deleted file mode 100644 index c398a13041..0000000000 --- a/collection/admin/Contest/Block User During Contest/[ERR] Blocked User.bru +++ /dev/null @@ -1,48 +0,0 @@ -meta { - name: [ERR] Blocked User - type: graphql - seq: 6 -} - -post { - url: {{gqlUrl}} - body: graphql - auth: none -} - -body:graphql { - mutation BlockUserDuringContest( - $contestId: Int!, - $userId: Int! - ) { - blockUserDuringContest( - contestId: $contestId, - userId: $userId - ) { - userId - } - } -} - -body:graphql:vars { - { - "contestId": 20, - "userId": 10 - } -} - -script:pre-request { - await require("./login").loginContestAdmin(req); -} - -docs { - # 📙 [ERR] Blocked User - - **POST** `/graphql (mutation: blockUserDuringContest)` - - The user is already blocked. - - 차단하려는 대상(`userId`)이 이미 해당 대회에서 차단되었을 때, - - `ForbiddenAccessException`이 발생합니다. -} diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent Contest.bru b/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent Contest.bru deleted file mode 100644 index 4dcbb85ba0..0000000000 --- a/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent Contest.bru +++ /dev/null @@ -1,48 +0,0 @@ -meta { - name: [ERR] Nonexistent Contest - type: graphql - seq: 4 -} - -post { - url: {{gqlUrl}} - body: graphql - auth: none -} - -body:graphql { - mutation BlockUserDuringContest( - $contestId: Int!, - $userId: Int! - ) { - blockUserDuringContest( - contestId: $contestId, - userId: $userId - ) { - userId - } - } -} - -body:graphql:vars { - { - "contestId": 999, - "userId": 7 - } -} - -script:pre-request { - await require("./login").loginContestAdmin(req); -} - -docs { - # 📙 [ERR] Nonexistent Contest - - **POST** `/graphql (mutation: blockUserDuringContest)` - - The requested contest does not exist. - - 존재하지 않는 `contestId`를 요청했을 때, - - `EntityNotExistException`이 발생합니다. -} diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru b/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru deleted file mode 100644 index 9ec42b3f7e..0000000000 --- a/collection/admin/Contest/Block User During Contest/[ERR] Not Contest Staff.bru +++ /dev/null @@ -1,54 +0,0 @@ -meta { - name: [ERR] Not Contest Staff - type: graphql - seq: 2 -} - -post { - url: {{gqlUrl}} - body: graphql - auth: none -} - -body:graphql { - mutation BlockUserDuringContest( - $contestId: Int!, - $userId: Int! - ) { - blockUserDuringContest( - contestId: $contestId, - userId: $userId - ) { - userId - contestId - role - } - } -} - -body:graphql:vars { - { - "contestId": 1, - "userId": 7 - } -} - -assert { - res.body.errors[0].message: eq Only Admin or Manager can block users from contest -} - -script:pre-request { - await require("./login").loginContestAdmin(req); -} - -docs { - [ERR] Not Contest Staff - - **POST** `/graphql (mutation: blockUserDuringContest)` - - Only Admin or Manager can block users from contest. - - 요청자가 해당 대회의 `Admin` 또는 `Manager`가 아닐 때, - - `ForbiddenAccessException`이 발생합니다. -} diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru b/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru deleted file mode 100644 index e797870b98..0000000000 --- a/collection/admin/Contest/Block User During Contest/[ERR] Not Started Or Ended Contest.bru +++ /dev/null @@ -1,52 +0,0 @@ -meta { - name: [ERR] Not Started Or Ended Contest - type: graphql - seq: 3 -} - -post { - url: {{gqlUrl}} - body: graphql - auth: none -} - -body:graphql { - mutation BlockUserDuringContest( - $contestId: Int!, - $userId: Int! - ) { - blockUserDuringContest( - contestId: $contestId, - userId: $userId - ) { - userId - } - } -} - -body:graphql:vars { - { - "contestId": 19, - "userId": 7 - } -} - -assert { - res.body.errors[0].message: eq Cannot block user not started or ended contest -} - -script:pre-request { - await require("./login").loginContestAdmin(req); -} - -docs { - # 📙 [ERR] Not Started Or Ended Contest - - **POST** `/graphql (mutation: blockUserDuringContest)` - - Cannot block from not started or ended contest. - - 시작되기 전이나 종료된 대회에서 사용자를 차단하려고 할 때, - - `ForbiddenAccessException`이 발생합니다. -} diff --git a/collection/admin/Contest/Block User During Contest/folder.bru b/collection/admin/Contest/Block User During Contest/folder.bru deleted file mode 100644 index 5b0b8c266f..0000000000 --- a/collection/admin/Contest/Block User During Contest/folder.bru +++ /dev/null @@ -1,8 +0,0 @@ -meta { - name: Block User During Contest - seq: 19 -} - -auth { - mode: inherit -} diff --git a/collection/admin/Contest/Remove User From Contest/Succeed.bru b/collection/admin/Contest/Remove User From Contest/Succeed.bru index 2c72073922..6cd6fac6bc 100644 --- a/collection/admin/Contest/Remove User From Contest/Succeed.bru +++ b/collection/admin/Contest/Remove User From Contest/Succeed.bru @@ -28,13 +28,13 @@ body:graphql { body:graphql:vars { { - "contestId": 19, - "userId": 7 + "contestId": 20, + "userId": 8 } } assert { - res.body.data.removeProblemsFromContest: isDefined + res.body.data.removeUserFromContest: isDefined } script:pre-request { @@ -43,53 +43,58 @@ script:pre-request { docs { # 📘 Remove User From Contest - + **POST** `/graphql (mutation: removeUserFromContest)` - + Removes a user from a contest's registration list. - + 대회 참가자 명단에서 특정 사용자를 제거합니다. - - 이 작업은 대회의 `Admin` 또는 `Manager`만 수행할 수 있으며, **대회 시작 전에만** 가능합니다. - + + 이 작업은 대회의 `Admin` 또는 `Manager`만 수행할 수 있으며, **대회 종료 전에만** 가능합니다. + + 대회 시작 전인 경우 ContestRecord와 UserContest가 모두 제거됩니다. + + 대회 진행 중인 경우 UserContest만 제거됩니다. 제거된 사용자가 firstSolver였을 경우 다음에 푼 사람에게 넘어갑니다. + 제거된 사용자의 대회 역할 정보가 반환됩니다. - + --- - + ### 🔒 Authentication - + ✅ Required (Contest Staff) - + --- - + ### 📥 Request Variables - + | Name | Type | Required | Description | - |---|---|---|---| + | --- | --- | --- | --- | | `contestId` | Int | ✅ | 사용자를 제거할 대회의 ID | | `userId` | Int | ✅ | 제거될 사용자의 ID | - + --- - + ### 📤 Response Body - + #### Content-Type: `application/json` - + ```json { "data": { "removeUserFromContest": { - "userId": 7, - "contestId": 19, + "userId": 8, + "contestId": 20, "role": "Participant" } } } ``` - + | Field | Type | Description | - |--------|------|------| + | --- | --- | --- | | `userId` | Int | 사용자 ID | | `contestId` | Int | 대회 ID | | `role` | String | 제외된 사용자 역할 | + } diff --git a/collection/admin/Contest/Remove User From Contest/[ERR] Started Contest.bru b/collection/admin/Contest/Remove User From Contest/[ERR] Ended Contest.bru similarity index 64% rename from collection/admin/Contest/Remove User From Contest/[ERR] Started Contest.bru rename to collection/admin/Contest/Remove User From Contest/[ERR] Ended Contest.bru index c18f92557f..437e698443 100644 --- a/collection/admin/Contest/Remove User From Contest/[ERR] Started Contest.bru +++ b/collection/admin/Contest/Remove User From Contest/[ERR] Ended Contest.bru @@ -1,5 +1,5 @@ meta { - name: [ERR] Started Contest + name: [ERR] Ended Contest type: graphql seq: 2 } @@ -26,8 +26,8 @@ body:graphql { body:graphql:vars { { - "contestId": 1, - "userId": 7 + "contestId": 6, + "userId": 6 } } @@ -36,17 +36,17 @@ assert { } script:pre-request { - await require("./login").loginAdmin(req); + await require("./login").loginContestAdmin(req); } docs { - # 📙 [ERR] Started Contest - + # 📙 \[ERR\] Ended Contest + **POST** `/graphql (mutation: removeUserFromContest)` - - Cannot unregister from ongoing or ended contest. - - 이미 시작되었거나 종료된 대회에서 사용자를 제외하려고 할 때, - + + Cannot unregister from ended contest. + + 이미 종료된 대회에서 사용자를 제외하려고 할 때, + `ForbiddenAccessException`이 발생합니다. } diff --git a/collection/admin/Contest/Remove User From Contest/[ERR] Nonexistent ContestRecord.bru b/collection/admin/Contest/Remove User From Contest/[ERR] Nonexistent ContestRecord.bru index 38a7bd16dc..814af1c641 100644 --- a/collection/admin/Contest/Remove User From Contest/[ERR] Nonexistent ContestRecord.bru +++ b/collection/admin/Contest/Remove User From Contest/[ERR] Nonexistent ContestRecord.bru @@ -1,7 +1,7 @@ meta { name: [ERR] Nonexistent ContestRecord type: graphql - seq: 5 + seq: 6 } post { @@ -27,7 +27,7 @@ body:graphql { body:graphql:vars { { "contestId": 19, - "userId": 999 + "userId": 6 } } @@ -36,13 +36,13 @@ script:pre-request { } docs { - # 📙 [ERR] Nonexistent ContestRecord - + # 📙 \[ERR\] Nonexistent ContestRecord + **POST** `/graphql (mutation: removeUserFromContest)` - - The user is not registered for the contest. - - 제거하려는 대상(`userId`)이 해당 대회의 참가자 명단에 없을 때, - + + The user does not have a record for the contest. + + 제거하려는 대상(`userId`)이 해당 대회의 기록을 가지고 있지 않을 때, + `EntityNotExistException`이 발생합니다. } diff --git a/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent ContestRecord.bru b/collection/admin/Contest/Remove User From Contest/[ERR] Nonexistent UserContest.bru similarity index 54% rename from collection/admin/Contest/Block User During Contest/[ERR] Nonexistent ContestRecord.bru rename to collection/admin/Contest/Remove User From Contest/[ERR] Nonexistent UserContest.bru index f19fce39ea..f27ba01897 100644 --- a/collection/admin/Contest/Block User During Contest/[ERR] Nonexistent ContestRecord.bru +++ b/collection/admin/Contest/Remove User From Contest/[ERR] Nonexistent UserContest.bru @@ -1,5 +1,5 @@ meta { - name: [ERR] Nonexistent ContestRecord + name: [ERR] Nonexistent UserContest type: graphql seq: 5 } @@ -11,11 +11,11 @@ post { } body:graphql { - mutation BlockUserDuringContest( + mutation RemoveUserFromContest( $contestId: Int!, $userId: Int! ) { - blockUserDuringContest( + removeUserFromContest( contestId: $contestId, userId: $userId ) { @@ -26,7 +26,7 @@ body:graphql { body:graphql:vars { { - "contestId": 20, + "contestId": 19, "userId": 999 } } @@ -36,13 +36,13 @@ script:pre-request { } docs { - # 📙 [ERR] Nonexistent ContestRecord + # 📙 \[ERR\] Nonexistent UserContest - **POST** `/graphql (mutation: blockUserDuringContest)` + **POST** `/graphql (mutation: removeUserFromContest)` - The user is not registered for the contest. + The user is unregistered for the contest. - 차단하려는 대상(`userId`)이 해당 대회의 참가자 명단에 없을 때, + 제거하려는 대상(`userId`)이 해당 대회에 참여하고 있지 않을 때, `EntityNotExistException`이 발생합니다. } diff --git a/collection/admin/Contest/Remove User From Contest/[ERR] Not Contest Staff.bru b/collection/admin/Contest/Remove User From Contest/[ERR] Not Contest Staff.bru index 79a749259f..454d798731 100644 --- a/collection/admin/Contest/Remove User From Contest/[ERR] Not Contest Staff.bru +++ b/collection/admin/Contest/Remove User From Contest/[ERR] Not Contest Staff.bru @@ -1,7 +1,7 @@ meta { name: [ERR] Not Contest Staff type: graphql - seq: 2 + seq: 3 } post { @@ -33,22 +33,22 @@ body:graphql:vars { } } -script:pre-request { - await require("./login").loginContestAdmin(req); -} - assert { res.body.errors: isDefined } +script:pre-request { + await require("./login").loginContestAdmin(req); +} + docs { [ERR] Not Contest Staff - + **POST** `/graphql (mutation: removeUserFromContest)` - + Only Admin or Manager can remove users from contest. - + 요청자가 해당 대회의 `Admin` 또는 `Manager`가 아닐 때, - + `ForbiddenAccessException`이 발생합니다. } diff --git a/collection/admin/Contest/Remove User From Contest/folder.bru b/collection/admin/Contest/Remove User From Contest/folder.bru new file mode 100644 index 0000000000..1fd33ccd3d --- /dev/null +++ b/collection/admin/Contest/Remove User From Contest/folder.bru @@ -0,0 +1,4 @@ +meta { + name: Remove User From Contest + seq: 18 +} From 806767c7c9d41f7aa9ba4b5125be4d870d447292 Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Wed, 26 Aug 2026 23:23:48 +0900 Subject: [PATCH 14/15] test(be): update removeUserFromContest test --- .../apps/admin/src/contest/test/contest.service.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/apps/admin/src/contest/test/contest.service.spec.ts b/apps/backend/apps/admin/src/contest/test/contest.service.spec.ts index c4fe91ed00..5a34e0cd15 100644 --- a/apps/backend/apps/admin/src/contest/test/contest.service.spec.ts +++ b/apps/backend/apps/admin/src/contest/test/contest.service.spec.ts @@ -538,9 +538,9 @@ describe('ContestService', () => { expect(db.$transaction.calledOnce).to.be.true }) - it('should throw ForbiddenAccessException if contest started', async () => { - const startedContest = { ...contest, startTime: faker.date.past() } - db.contest.findUnique.resolves(startedContest) + it('should throw ForbiddenAccessException if contest ended', async () => { + const endedContest = { ...contest, endTime: faker.date.past() } + db.contest.findUnique.resolves(endedContest) db.contestRecord.findFirst.resolves(contestRecord) db.userContest.findUnique.resolves(userContest) From 6bdd5276b962a62c89f7ebff87874b1aa9b526bc Mon Sep 17 00:00:00 2001 From: Suuuuug Date: Wed, 26 Aug 2026 23:49:22 +0900 Subject: [PATCH 15/15] chore(be): exclude user without userContest with from participatedResult and registeredNum --- .../apps/admin/src/contest/contest.service.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index 50f7e98b5b..73894f472a 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.ts @@ -1213,10 +1213,24 @@ export class ContestService { }), this.prisma.submission.groupBy({ by: ['userId'], - where: { contestId } + where: { + contestId, + user: { + userContest: { + some: { contestId } + } + } + } }), this.prisma.contestRecord.count({ - where: { contestId } + where: { + contestId, + user: { + userContest: { + some: { contestId } + } + } + } }), // Contest의 최고 점수 계산 this.prisma.contestProblem.aggregate({