diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index cda1434aee..73894f472a 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,30 +636,44 @@ 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 } - }) - ]) + const [contest, userContest, contestRecord, requesterRole] = + await Promise.all([ + this.prisma.contest.findUnique({ + where: { id: contestId }, + select: { startTime: true, endTime: true } + }), + this.prisma.userContest.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_contestId: { + userId, + contestId + } + }, + select: { id: 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 (!userContest) { + throw new EntityNotExistException('UserContest') + } if (!contestRecord) { throw new EntityNotExistException('ContestRecord') } @@ -673,19 +688,74 @@ export class ContestService { } const now = new Date() - if (now >= contest.startTime) { - throw new ForbiddenAccessException( - 'Cannot unregister ongoing or ended contest' - ) + if (now >= contest.endTime) { + throw new ForbiddenAccessException('Cannot unregister 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 } + if (now >= contest.startTime) { + // 강퇴당한 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: { + 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.delete({ // eslint-disable-next-line @typescript-eslint/naming-convention @@ -1143,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({ @@ -1156,7 +1240,14 @@ export class ContestService { }), // 항상 finalScore, finalTotalPenalty 사용 this.prisma.contestRecord.findMany({ - where: { contestId }, + where: { + contestId, + user: { + userContest: { + some: { contestId } + } + } + }, select: { userId: true, user: { select: { username: true } }, 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) diff --git a/apps/backend/apps/client/src/contest/contest.service.ts b/apps/backend/apps/client/src/contest/contest.service.ts index baf7a0df4c..1b3bb38e50 100644 --- a/apps/backend/apps/client/src/contest/contest.service.ts +++ b/apps/backend/apps/client/src/contest/contest.service.ts @@ -404,7 +404,14 @@ export class ContestService { const maxScore = sum._sum?.score ?? 0 const contestRecordsPromise = this.prisma.contestRecord.findMany({ - where: { contestId }, + where: { + contestId, + user: { + userContest: { + some: { contestId } + } + } + }, select: { userId: true, user: { diff --git a/apps/backend/apps/client/src/submission/submission-finalization.service.ts b/apps/backend/apps/client/src/submission/submission-finalization.service.ts index f67397045c..986fdd81ff 100644 --- a/apps/backend/apps/client/src/submission/submission-finalization.service.ts +++ b/apps/backend/apps/client/src/submission/submission-finalization.service.ts @@ -205,6 +205,7 @@ export class SubmissionFinalizationService { finalScore: score, finalTimePenalty: timePenalty, finalSubmitCountPenalty: submitCountPenalty, + finishTime: updateTime, ...(!isFreezed ? { score, submitCountPenalty, timePenalty } : {}) } diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index 4247a677b5..a33a281f49 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -130,7 +130,7 @@ export class SubmissionService { * @returns {Promise} 생성된 제출 객체 * @throws {EntityNotExistException} 아래의 경우에 발생합니다 * - 유효한 진행 중인 대회가 없을 경우 (Contest) - * - 사용자가 대회에 등록되어 있지 않은 경우 (ContestRecord) + * - 사용자가 대회에 등록되어 있지 않은 경우 (UserContest) * - 문제를 찾을 수 없거나 대회와 매칭되지 않는 경우 (ContestProblem) * @throws {ConflictFoundException} 아래의 경우에 발생합니다 * - 대회가 진행중이지 않을 경우 @@ -166,30 +166,17 @@ export class SubmissionService { if (!isStaff) { // 대회에 등록되어 있는지 확인합니다. - const contestRecord = await this.prisma.contestRecord.findUnique({ + const userContest = await this.prisma.userContest.findUnique({ where: { // eslint-disable-next-line @typescript-eslint/naming-convention - contestId_userId: { - contestId, - userId - } - }, - select: { - contest: { - select: { - startTime: true, - endTime: true - } - } + userId_contestId: { userId, contestId } } }) - if (!contestRecord) { - throw new EntityNotExistException('ContestRecord') + + if (!userContest) { + throw new EntityNotExistException('UserContest') } - 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' ) 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 79496b85e2..6c15f45582 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 @@ -78,7 +78,8 @@ const db = { update: stub() }, userContest: { - findFirst: stub() + findFirst: stub(), + findUnique: stub() }, assignmentRecord: { findUnique: stub(), @@ -264,14 +265,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] }) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 8881be02b7..bdb7d3e55d 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -5039,6 +5039,41 @@ const createContestRecords = async () => { contestRecords.push(updated) } + + // 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({ + data: { contestId: 20, userId: user.id } + }) + contestRecords.push(record) + } + const alreadyRemovedUser = users[3] // user04 + await prisma.contestRecord.create({ + 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: contestReviewerUser.id, + contestId: endedContest.id, + role: ContestRole.Participant + } + }) } const createUserContests = async () => { @@ -5181,6 +5216,74 @@ 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 || 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({ + 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/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/Remove User From Contest/[ERR] Nonexistent UserContest.bru b/collection/admin/Contest/Remove User From Contest/[ERR] Nonexistent UserContest.bru new file mode 100644 index 0000000000..f27ba01897 --- /dev/null +++ b/collection/admin/Contest/Remove User From Contest/[ERR] Nonexistent UserContest.bru @@ -0,0 +1,48 @@ +meta { + name: [ERR] Nonexistent UserContest + type: graphql + seq: 5 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation RemoveUserFromContest( + $contestId: Int!, + $userId: Int! + ) { + removeUserFromContest( + contestId: $contestId, + userId: $userId + ) { + userId + } + } +} + +body:graphql:vars { + { + "contestId": 19, + "userId": 999 + } +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +docs { + # 📙 \[ERR\] Nonexistent UserContest + + **POST** `/graphql (mutation: removeUserFromContest)` + + The user is unregistered for the contest. + + 제거하려는 대상(`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 +}