diff --git a/apps/backend/apps/admin/src/contest/contest.resolver.ts b/apps/backend/apps/admin/src/contest/contest.resolver.ts index bfd78e510e..1b63f14183 100644 --- a/apps/backend/apps/admin/src/contest/contest.resolver.ts +++ b/apps/backend/apps/admin/src/contest/contest.resolver.ts @@ -204,6 +204,19 @@ export class ContestResolver { ) { return await this.contestService.getContestParticipants(contestId) } + + @Mutation(() => UserContest) + async toggleParticipantBlocking( + @Context('req') req: AuthenticatedRequest, + @Args('userId', { type: () => Int }) targetUserId: number, + @Args('contestId', { type: () => Int }) contestId: number + ) { + return await this.contestService.toggleParticipantBlocking( + req.user, + targetUserId, + contestId + ) + } } @Resolver(() => ContestProblem) diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index cda1434aee..f070fc04b7 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.ts @@ -4,6 +4,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter' import { Contest, ResultStatus, Submission } from '@generated' import { ContestRole, Prisma, Role } from '@prisma/client' import { Cache } from 'cache-manager' +import type { AuthenticatedUser } from '@libs/auth' import { MAX_DATE } from '@libs/constants' import { EntityNotExistException, @@ -1371,4 +1372,48 @@ export class ContestService { isBlocked })) } + + async toggleParticipantBlocking( + user: AuthenticatedUser, + targetUserId: number, + contestId: number + ) { + const [userContest, contestStaff] = await Promise.all([ + this.prisma.userContest.findFirst({ + where: { + userId: targetUserId, + contestId + } + }), + this.prisma.userContest.findFirst({ + where: { + userId: user.id, + contestId, + role: { in: [ContestRole.Admin, ContestRole.Manager] } + }, + select: { role: true } + }) + ]) + + if (!userContest) { + throw new EntityNotExistException('UserContest') + } + + if (userContest.role != ContestRole.Participant) { + throw new UnprocessableDataException( + 'Only can block Contest Participant.' + ) + } + + if (!contestStaff && !user.isSuperAdmin()) { + throw new ForbiddenAccessException( + 'Only Contest Admin or Manager can modify block status.' + ) + } + + return await this.prisma.userContest.update({ + where: { id: userContest.id }, + data: { isBlocked: !userContest.isBlocked } + }) + } } diff --git a/apps/backend/apps/client/src/contest/contest.service.ts b/apps/backend/apps/client/src/contest/contest.service.ts index f8c317e744..3c626e088f 100644 --- a/apps/backend/apps/client/src/contest/contest.service.ts +++ b/apps/backend/apps/client/src/contest/contest.service.ts @@ -643,7 +643,7 @@ export class ContestService { // 대회 진행 중인 경우 대회에 등록한 참가자 또는 관리자만 질문 게시 가능 if (isOngoing) { const hasRegistered = await this.prisma.userContest.findFirst({ - where: { userId, contestId } + where: { userId, contestId, isBlocked: false } }) if (!hasRegistered) { const user = await this.prisma.user.findFirst({ diff --git a/apps/backend/schema.gql b/apps/backend/schema.gql index dddc41e673..b4ab56f6b5 100644 --- a/apps/backend/schema.gql +++ b/apps/backend/schema.gql @@ -2166,6 +2166,7 @@ type Mutation { removeUserFromContest(contestId: Int!, userId: Int!): UserContest! revokeInvitation(groupId: Int!): String! toggleContestQnAResolved(contestId: Int!, qnAOrder: Int!): ContestQnA! + toggleParticipantBlocking(contestId: Int!, userId: Int!): UserContest! updateAnnouncement(contestId: Int!, input: UpdateAnnouncementInput!): Announcement! updateAssignment(groupId: Int!, input: UpdateAssignmentInput!): Assignment! updateAssignmentProblemRecord(groupId: Int!, input: UpdateAssignmentProblemRecordInput!): AssignmentProblemRecord! diff --git a/collection/admin/Contest/Toggle Block Participant/Succeed.bru b/collection/admin/Contest/Toggle Block Participant/Succeed.bru new file mode 100644 index 0000000000..0fc02d4bd2 --- /dev/null +++ b/collection/admin/Contest/Toggle Block Participant/Succeed.bru @@ -0,0 +1,87 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: inherit +} + +body:graphql { + mutation ToggleParticipantBlocking( + $userId: Int!, + $contestId: Int! + ) { + toggleParticipantBlocking( + userId: $userId + contestId: $contestId + ) { + id + role + userId + contestId + isBlocked + } + } +} + +body:graphql:vars { + { + "userId" : 7, + "contestId" : 19 + } +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + # 📘 Toggle Block Participant + + **POST** `/graphql (mutation: toggleBlockParticipant)` + + 대회 참가자에 대해 block 토글을 수행합니다. + + --- + + ### 🔒 Authentication + + ✅ Required (Contest Admin / Manager, SuperAdmin) + + --- + + ## 요청 필드 + | 필드명 | 타입 | 필수 여부 | 설명 | + |--------|------|----------|------| + | `userId` | Int | ✅ | 사용자 Id | + | `contestId` | Int | ✅ | 대회 Id | + + --- + + ### 📤 Response Body + + #### Content-Type: `application/json` + + ```json + { + "data": { + "toggleParticipantBlocking": { + "id": "47", + "role": "Participant", + "userId": 7, + "contestId": 19, + "isBlocked": false + } + } + } + ``` +} diff --git a/collection/admin/Contest/Toggle Block Participant/[ERR] Nonexistent UserContest.bru b/collection/admin/Contest/Toggle Block Participant/[ERR] Nonexistent UserContest.bru new file mode 100644 index 0000000000..5f179e0ef1 --- /dev/null +++ b/collection/admin/Contest/Toggle Block Participant/[ERR] Nonexistent UserContest.bru @@ -0,0 +1,87 @@ +meta { + name: [ERR] Nonexistent UserContest + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: inherit +} + +body:graphql { + mutation ToggleParticipantBlocking( + $userId: Int!, + $contestId: Int! + ) { + toggleParticipantBlocking( + userId: $userId + contestId: $contestId + ) { + id + role + userId + contestId + isBlocked + } + } +} + +body:graphql:vars { + { + "userId" : 999, + "contestId" : 19 + } +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + # 📘 Toggle Block Participant + + **POST** `/graphql (mutation: toggleBlockParticipant)` + + 대회 참가자에 대해 block 토글을 수행합니다. + + --- + + ### 🔒 Authentication + + ✅ Required (Contest Admin / Manager, SuperAdmin) + + --- + + ## 요청 필드 + | 필드명 | 타입 | 필수 여부 | 설명 | + |--------|------|----------|------| + | `userId` | Int | ✅ | 사용자 Id | + | `contestId` | Int | ✅ | 대회 Id | + + --- + + ### 📤 Response Body + + #### Content-Type: `application/json` + + ```json + { + "data": { + "toggleParticipantBlocking": { + "id": "47", + "role": "Participant", + "userId": 7, + "contestId": 19, + "isBlocked": false + } + } + } + ``` +} diff --git a/collection/admin/Contest/Toggle Block Participant/[ERR] Only can block Contest Participant.bru b/collection/admin/Contest/Toggle Block Participant/[ERR] Only can block Contest Participant.bru new file mode 100644 index 0000000000..51ecc53f9f --- /dev/null +++ b/collection/admin/Contest/Toggle Block Participant/[ERR] Only can block Contest Participant.bru @@ -0,0 +1,87 @@ +meta { + name: [ERR] Only can block Contest Participant + type: graphql + seq: 3 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: inherit +} + +body:graphql { + mutation ToggleParticipantBlocking( + $userId: Int!, + $contestId: Int! + ) { + toggleParticipantBlocking( + userId: $userId + contestId: $contestId + ) { + id + role + userId + contestId + isBlocked + } + } +} + +body:graphql:vars { + { + "userId" : 4, + "contestId" : 19 + } +} + +script:pre-request { + await require("./login").loginContestAdmin(req); +} + +settings { + encodeUrl: true + timeout: 0 +} + +docs { + # 📘 Toggle Block Participant + + **POST** `/graphql (mutation: toggleBlockParticipant)` + + 대회 참가자에 대해 block 토글을 수행합니다. + + --- + + ### 🔒 Authentication + + ✅ Required (Contest Admin / Manager, SuperAdmin) + + --- + + ## 요청 필드 + | 필드명 | 타입 | 필수 여부 | 설명 | + |--------|------|----------|------| + | `userId` | Int | ✅ | 사용자 Id | + | `contestId` | Int | ✅ | 대회 Id | + + --- + + ### 📤 Response Body + + #### Content-Type: `application/json` + + ```json + { + "data": { + "toggleParticipantBlocking": { + "id": "47", + "role": "Participant", + "userId": 7, + "contestId": 19, + "isBlocked": false + } + } + } + ``` +} diff --git a/collection/admin/Contest/Toggle Block Participant/folder.bru b/collection/admin/Contest/Toggle Block Participant/folder.bru new file mode 100644 index 0000000000..1343c7cb05 --- /dev/null +++ b/collection/admin/Contest/Toggle Block Participant/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Toggle Block Participant + seq: 22 +} + +auth { + mode: inherit +} diff --git a/collection/admin/login.js b/collection/admin/login.js index 13c59a5783..c9f26437fb 100644 --- a/collection/admin/login.js +++ b/collection/admin/login.js @@ -12,6 +12,13 @@ const login = async (req, body) => { } } +const loginSuper = async (req) => { + await login(req, { + username: 'super', + password: 'Supersuper' + }) +} + const loginAdmin = async (req) => { await login(req, { username: 'admin', @@ -56,6 +63,7 @@ const loginUser = async (req) => { } module.exports = { + loginSuper, loginAdmin, loginContestAdmin, loginContestManager,