diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 194472db27..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,8 +0,0 @@ -dist -node_modules -.pnpm-store -@generated -collection -.next -.eslintrc.js -*.config.js diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index cfaa4ccc7f..0000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -module.exports = { - env: { node: true }, - root: true, - parser: '@typescript-eslint/parser', - parserOptions: { - project: true, - emitDecoratorMetadata: true, - ecmaFeatures: { - jsx: true - } - }, - plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:prettier/recommended' - ], - rules: { - '@typescript-eslint/consistent-type-imports': 'warn', - '@typescript-eslint/no-import-type-side-effects': 'error', - '@typescript-eslint/no-inferrable-types': 'warn', - 'func-style': ['error', 'expression'], - 'no-restricted-imports': [ - 'error', - { - patterns: [ - { - group: ['apps/*', 'libs/*'], - message: 'Please import with path alias like `@apps/*` or `@libs/*`' - } - ] - } - ], - 'object-shorthand': ['warn', 'always'] - }, - overrides: [ - { - /* Do not apply `naming-convention` rule to tsx files */ - files: ['*.ts'], - rules: { - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'default', - format: ['camelCase'], - leadingUnderscore: 'allow', - trailingUnderscore: 'allow' - }, - { - selector: 'import', - format: ['camelCase', 'PascalCase'] - }, - { - selector: 'variable', - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - leadingUnderscore: 'allow', - trailingUnderscore: 'allow' - }, - { - selector: 'typeLike', - format: ['PascalCase'] - }, - { - selector: ['objectLiteralProperty', 'classProperty'], - format: ['camelCase', 'PascalCase'] - } - ] - } - } - ] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 373d37fefc..f5abd93603 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,8 +129,9 @@ jobs: with: install: 'no' - - name: Install dev dependencies - run: pnpm install --dev + # NOTE: eslint-config-next 에서 next 모듈을 찾지 못하는 에러를 해결하기 위해 프론트엔드 의존성을 설치합니다. + - name: Install root and frontend dependencies + run: pnpm install -w --filter frontend - name: Set up Go uses: actions/setup-go@v5 @@ -208,3 +209,30 @@ jobs: - name: Test if: steps.filter.outputs.backend == 'true' run: pnpm --filter="@codedang/backend" test + + test-iris: + name: Test Iris + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Check if source code has changed + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + iris: + - 'apps/iris/**' + + - name: Set up Go + if: steps.filter.outputs.iris == 'true' + uses: actions/setup-go@v5 + with: + go-version-file: apps/iris/go.mod + cache-dependency-path: apps/iris/go.sum + + - name: Test (Go) + if: steps.filter.outputs.iris == 'true' + run: go test ./... + working-directory: ./apps/iris diff --git a/.github/workflows/rc-build-image.yml b/.github/workflows/rc-build-image.yml new file mode 100644 index 0000000000..464ad3ea1a --- /dev/null +++ b/.github/workflows/rc-build-image.yml @@ -0,0 +1,90 @@ +name: RC - CD - Build Images + +on: + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + ECS_CLUSTER: Codedang-Api + +permissions: # permissions to the job (for the OpenID Connection) + id-token: write + contents: read + +jobs: + build-client-api: + name: Build client-api image + runs-on: ubuntu-latest + steps: + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_ECR_PUSH_RC }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + file: ./apps/backend/Dockerfile + push: true + build-args: | + target=client + app_env=production + tags: ${{ steps.login-ecr.outputs.registry }}/codedang-client-api:latest + + build-admin-api: + name: Build admin-api image + runs-on: ubuntu-latest + steps: + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_ECR_PUSH_RC }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + file: ./apps/backend/Dockerfile + push: true + build-args: | + target=admin + app_env=production + tags: ${{ steps.login-ecr.outputs.registry }}/codedang-admin-api:latest + + build-iris: + name: Build iris Docker image + runs-on: ubuntu-latest + steps: + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_ECR_PUSH_RC }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push image (iris) + uses: docker/build-push-action@v6 + with: + push: true + context: '{{defaultContext}}:apps/iris' + build-args: | + app_env=production + tags: ${{ steps.login-ecr.outputs.registry }}/codedang-iris:latest diff --git a/.github/workflows/rc-deploy-target.yml b/.github/workflows/rc-deploy-target.yml new file mode 100644 index 0000000000..e40530fcea --- /dev/null +++ b/.github/workflows/rc-deploy-target.yml @@ -0,0 +1,66 @@ +name: RC - Deploy - Target + +on: + workflow_dispatch: + inputs: + terraform_project: + description: 'Select Terraform Project to Deploy' + required: true + type: choice + options: + - 'network' + - 'storage' + - 'codedang' + +env: + AWS_REGION: ap-northeast-2 + ECS_CLUSTER: Codedang-Api + +permissions: # permissions to the job (for the OpenID Connection) + id-token: write + contents: read + +jobs: + rc-deploy-target-project: + name: RC - Deploy Terraform targeted Project + runs-on: ubuntu-latest + environment: production + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_DEPLOY_RC }} + aws-region: ${{ env.AWS_REGION }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.5.2 + + - name: Create Terraform variable file + working-directory: ./apps/infra/rc/${{ github.event.inputs.terraform_project }} + run: | + echo "$TFVARS_RC" >> terraform.tfvars + echo "$OAUTH_GITHUB" >> terraform.tfvars + echo "$OAUTH_KAKAO" >> terraform.tfvars + echo 'env = "rc"' >> terraform.tfvars + env: + TFVARS_RC: ${{ secrets.TFVARS_RC }} + OAUTH_GITHUB: ${{ secrets.OAUTH_GITHUB }} + OAUTH_KAKAO: ${{ secrets.OAUTH_KAKAO }} + + - name: Terraform Init + working-directory: ./apps/infra/rc/${{ github.event.inputs.terraform_project }} + run: terraform init -backend-config="bucket=codedang-tf-state-rc" + + - name: Terraform Plan + working-directory: ./apps/infra/rc/${{ github.event.inputs.terraform_project }} + run: terraform plan -input=false -out=plan.out + + - name: Terraform Apply + working-directory: ./apps/infra/rc/${{ github.event.inputs.terraform_project }} + run: terraform apply -input=false plan.out diff --git a/.github/workflows/rc-deploy.yml b/.github/workflows/rc-deploy.yml new file mode 100644 index 0000000000..5b217dbd02 --- /dev/null +++ b/.github/workflows/rc-deploy.yml @@ -0,0 +1,133 @@ +name: RC - Deploy + +on: + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + ECS_CLUSTER: Codedang-Api + +permissions: # permissions to the job (for the OpenID Connection) + id-token: write + contents: read + +jobs: + rc-deploy-network: + name: RC - Deploy Network + runs-on: ubuntu-latest + environment: production + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_DEPLOY_RC }} + aws-region: ${{ env.AWS_REGION }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.5.2 + + - name: Create Terraform variable file + working-directory: ./apps/infra/rc/network + run: | + echo 'env = "rc"' >> terraform.tfvars + + - name: Terraform Init + working-directory: ./apps/infra/rc/network + run: terraform init -backend-config="bucket=codedang-tf-state-rc" + + - name: Terraform Plan + working-directory: ./apps/infra/rc/network + run: terraform plan -input=false -out=plan.out + + - name: Terraform Apply + working-directory: ./apps/infra/rc/network + run: terraform apply -input=false plan.out + + rc-deploy-storage: + name: RC - Deploy Storage + runs-on: ubuntu-latest + needs: [rc-deploy-network] + environment: production + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_DEPLOY_RC }} + aws-region: ${{ env.AWS_REGION }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.5.2 + + - name: Create Terraform variable file + working-directory: ./apps/infra/rc/storage + run: | + echo 'env = "rc"' >> terraform.tfvars + + - name: Terraform Init + working-directory: ./apps/infra/rc/storage + run: terraform init -backend-config="bucket=codedang-tf-state-rc" + + - name: Terraform Plan + working-directory: ./apps/infra/rc/storage + run: terraform plan -input=false -out=plan.out + + - name: Terraform Apply + working-directory: ./apps/infra/rc/storage + run: terraform apply -input=false plan.out + + rc-deploy-codedang: + name: RC - Deploy Codedang + runs-on: ubuntu-latest + needs: [rc-deploy-network, rc-deploy-storage] + environment: production + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_DEPLOY_RC }} + aws-region: ${{ env.AWS_REGION }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.5.2 + + - name: Create Terraform variable file + working-directory: ./apps/infra/rc/codedang + run: | + echo "$TFVARS_RC" >> terraform.tfvars + echo "$OAUTH_GITHUB" >> terraform.tfvars + echo "$OAUTH_KAKAO" >> terraform.tfvars + echo 'env = "rc"' >> terraform.tfvars + env: + TFVARS_RC: ${{ secrets.TFVARS_RC }} + OAUTH_GITHUB: ${{ secrets.OAUTH_GITHUB }} + OAUTH_KAKAO: ${{ secrets.OAUTH_KAKAO }} + + - name: Terraform Init + working-directory: ./apps/infra/rc/codedang + run: terraform init -backend-config="bucket=codedang-tf-state-rc" + + - name: Terraform Plan + working-directory: ./apps/infra/rc/codedang + run: terraform plan -input=false -out=plan.out + + - name: Terraform Apply + working-directory: ./apps/infra/rc/codedang + run: terraform apply -input=false plan.out diff --git a/.github/workflows/rc-destroy-target.yml b/.github/workflows/rc-destroy-target.yml new file mode 100644 index 0000000000..8d1990353a --- /dev/null +++ b/.github/workflows/rc-destroy-target.yml @@ -0,0 +1,60 @@ +name: RC - Destroy - Target + +on: + workflow_dispatch: + inputs: + terraform_project: + description: 'Select Terraform Project to Destroy' + required: true + type: choice + options: + - 'network' + - 'storage' + - 'codedang' + +env: + AWS_REGION: ap-northeast-2 + ECS_CLUSTER: Codedang-Api + +permissions: # permissions to the job (for the OpenID Connection) + id-token: write + contents: read + +jobs: + rc-destroy-terraform-target-project: + name: RC - Destroy Terraform targeted Project + runs-on: ubuntu-latest + environment: production + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_DEPLOY_RC }} + aws-region: ${{ env.AWS_REGION }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.5.2 + + - name: Create Terraform variable file + working-directory: ./apps/infra/rc/${{ github.event.inputs.terraform_project }} + run: | + echo "$TFVARS_RC" >> terraform.tfvars + echo "$OAUTH_GITHUB" >> terraform.tfvars + echo "$OAUTH_KAKAO" >> terraform.tfvars + echo 'env = "rc"' >> terraform.tfvars + env: + TFVARS_RC: ${{ secrets.TFVARS_RC }} + OAUTH_GITHUB: ${{ secrets.OAUTH_GITHUB }} + OAUTH_KAKAO: ${{ secrets.OAUTH_KAKAO }} + + - name: Destroy + working-directory: ./apps/infra/rc/${{ github.event.inputs.terraform_project }} + run: | + terraform init -backend-config="bucket=codedang-tf-state-rc" + terraform destroy -auto-approve diff --git a/.github/workflows/rc-destroy.yml b/.github/workflows/rc-destroy.yml new file mode 100644 index 0000000000..eef921c579 --- /dev/null +++ b/.github/workflows/rc-destroy.yml @@ -0,0 +1,74 @@ +name: RC - destroy +#Except Terraform-Configuration Project + +on: + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + ECS_CLUSTER: Codedang-Api + +permissions: # permissions to the job (for the OpenID Connection) + id-token: write + contents: read + +jobs: + rc-destroy: + name: Destroy + runs-on: ubuntu-latest + environment: production + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_DEPLOY_RC }} + aws-region: ${{ env.AWS_REGION }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.5.2 + + - name: Create Terraform variable file (Codedang) + working-directory: ./apps/infra/rc/codedang + run: | + echo "$TFVARS_RC" >> terraform.tfvars + echo "$OAUTH_GITHUB" >> terraform.tfvars + echo "$OAUTH_KAKAO" >> terraform.tfvars + echo 'env = "rc"' >> terraform.tfvars + env: + TFVARS_RC: ${{ secrets.TFVARS_RC }} + OAUTH_GITHUB: ${{ secrets.OAUTH_GITHUB }} + OAUTH_KAKAO: ${{ secrets.OAUTH_KAKAO }} + + - name: Destroy Codedang + working-directory: ./apps/infra/rc/codedang + run: | + terraform init -backend-config="bucket=codedang-tf-state-rc" + terraform destroy -auto-approve + + - name: Create Terraform variable file (Storage) + working-directory: ./apps/infra/rc/storage + run: | + echo 'env = "rc"' >> terraform.tfvars + + - name: Destroy Storage + working-directory: ./apps/infra/rc/storage + run: | + terraform init -backend-config="bucket=codedang-tf-state-rc" + terraform destroy -auto-approve + + - name: Create Terraform variable file (Network) + working-directory: ./apps/infra/rc/network + run: | + echo 'env = "rc"' >> terraform.tfvars + + - name: Destroy Network + working-directory: ./apps/infra/rc/network + run: | + terraform init -backend-config="bucket=codedang-tf-state-rc" + terraform destroy -auto-approve diff --git a/.github/workflows/rc-init-config.yml b/.github/workflows/rc-init-config.yml new file mode 100644 index 0000000000..52504cd02f --- /dev/null +++ b/.github/workflows/rc-init-config.yml @@ -0,0 +1,50 @@ +name: RC - Init + +on: + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + ECS_CLUSTER: Codedang-Api + +permissions: # permissions to the job (for the OpenID Connection) + id-token: write + contents: read + +jobs: + rc-init-config: + name: RC - Init Config + runs-on: ubuntu-latest + environment: production + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_FOR_DEPLOY_RC }} + aws-region: ${{ env.AWS_REGION }} + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.5.2 + + - name: Create Terraform variable file + working-directory: ./apps/infra/rc/terraform-configuration + run: | + echo 'env = "rc"' >> terraform.tfvars + + - name: Terraform Init + working-directory: ./apps/infra/rc/terraform-configuration + run: terraform init -backend-config="bucket=codedang-tf-state-rc" + + - name: Terraform Plan + working-directory: ./apps/infra/rc/terraform-configuration + run: terraform plan -input=false -out=plan.out + + - name: Terraform Apply + working-directory: ./apps/infra/rc/terraform-configuration + run: terraform apply -input=false plan.out diff --git a/apps/backend/apps/admin/src/admin.module.ts b/apps/backend/apps/admin/src/admin.module.ts index 35b87db812..8b59be58d2 100644 --- a/apps/backend/apps/admin/src/admin.module.ts +++ b/apps/backend/apps/admin/src/admin.module.ts @@ -21,6 +21,7 @@ import { NoticeModule } from '@admin/notice/notice.module' import { AdminController } from './admin.controller' import { AdminService } from './admin.service' import { AnnouncementModule } from './announcement/announcement.module' +import { AssignmentModule } from './assignment/assignment.module' import { ContestModule } from './contest/contest.module' import { GroupModule } from './group/group.module' import { ProblemModule } from './problem/problem.module' @@ -50,6 +51,7 @@ import { UserModule } from './user/user.module' RolesModule, PrismaModule, ContestModule, + AssignmentModule, ProblemModule, StorageModule, GroupModule, diff --git a/apps/backend/apps/admin/src/assignment/assignment.module.ts b/apps/backend/apps/admin/src/assignment/assignment.module.ts new file mode 100644 index 0000000000..d07981049f --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/assignment.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { RolesModule } from '@libs/auth' +import { ProblemModule } from '@admin/problem/problem.module' +import { AssignmentResolver } from './assignment.resolver' +import { AssignmentService } from './assignment.service' + +@Module({ + imports: [RolesModule, ProblemModule], + providers: [AssignmentService, AssignmentResolver] +}) +export class AssignmentModule {} diff --git a/apps/backend/apps/admin/src/assignment/assignment.resolver.ts b/apps/backend/apps/admin/src/assignment/assignment.resolver.ts new file mode 100644 index 0000000000..6e83eb21ef --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/assignment.resolver.ts @@ -0,0 +1,247 @@ +import { ParseBoolPipe } from '@nestjs/common' +import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql' +import { Assignment, AssignmentProblem } from '@generated' +import { AuthenticatedRequest, UseRolesGuard } from '@libs/auth' +import { OPEN_SPACE_ID } from '@libs/constants' +import { + CursorValidationPipe, + GroupIDPipe, + IDValidationPipe, + RequiredIntPipe +} from '@libs/pipe' +import { AssignmentService } from './assignment.service' +import { AssignmentSubmissionSummaryForUser } from './model/assignment-submission-summary-for-user.model' +import { AssignmentWithParticipants } from './model/assignment-with-participants.model' +import { CreateAssignmentInput } from './model/assignment.input' +import { UpdateAssignmentInput } from './model/assignment.input' +import { AssignmentsGroupedByStatus } from './model/assignments-grouped-by-status.output' +import { DuplicatedAssignmentResponse } from './model/duplicated-assignment-response.output' +import { AssignmentProblemScoreInput } from './model/problem-score.input' +import { AssignmentPublicizingRequest } from './model/publicizing-request.model' +import { AssignmentPublicizingResponse } from './model/publicizing-response.output' +import { UserAssignmentScoreSummaryWithUserInfo } from './model/score-summary' + +@Resolver(() => Assignment) +export class AssignmentResolver { + constructor(private readonly assignmentService: AssignmentService) {} + + @Query(() => [AssignmentWithParticipants]) + async getAssignments( + @Args( + 'take', + { type: () => Int, defaultValue: 10 }, + new RequiredIntPipe('take') + ) + take: number, + @Args( + 'groupId', + { defaultValue: OPEN_SPACE_ID, type: () => Int }, + GroupIDPipe + ) + groupId: number, + @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) + cursor: number | null + ) { + return await this.assignmentService.getAssignments(take, groupId, cursor) + } + + @Query(() => AssignmentWithParticipants) + async getAssignment( + @Args( + 'assignmentId', + { type: () => Int }, + new RequiredIntPipe('assignmentId') + ) + assignmentId: number + ) { + return await this.assignmentService.getAssignment(assignmentId) + } + + @Mutation(() => Assignment) + async createAssignment( + @Args('input') input: CreateAssignmentInput, + @Args( + 'groupId', + { defaultValue: OPEN_SPACE_ID, type: () => Int }, + GroupIDPipe + ) + groupId: number, + @Context('req') req: AuthenticatedRequest + ) { + return await this.assignmentService.createAssignment( + groupId, + req.user.id, + input + ) + } + + @Mutation(() => Assignment) + async updateAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('input') input: UpdateAssignmentInput + ) { + return await this.assignmentService.updateAssignment(groupId, input) + } + + @Mutation(() => Assignment) + async deleteAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) assignmentId: number + ) { + return await this.assignmentService.deleteAssignment(groupId, assignmentId) + } + + /** + * Assignment의 소속 Group을 Open Space(groupId === 1)로 이동시키기 위한 요청(Publicizing Requests)들을 불러옵니다. + * @returns Publicizing Request 배열 + */ + @Query(() => [AssignmentPublicizingRequest]) + @UseRolesGuard() + async getPublicizingRequests() { + return await this.assignmentService.getPublicizingRequests() + } + + /** + * Assignment 소속 Group을 Open Space(groupId === 1)로 이동시키기 위한 요청(Publicizing Request)를 생성합니다. + * @param groupId Assignment가 속한 Group의 ID. 이미 Open Space(groupId === 1)이 아니어야 합니다. + * @param assignemtnId Assignment ID + * @returns 생성된 Publicizing Request + */ + @Mutation(() => AssignmentPublicizingRequest) + async createPublicizingRequest( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) assignmentId: number + ) { + return await this.assignmentService.createPublicizingRequest( + groupId, + assignmentId + ) + } + + /** + * Assignment 소속 Group을 Open Space(groupId === 1)로 이동시키기 위한 요청(Publicizing Request)을 처리합니다. + * @param assignmentId Publicizing Request를 생성한 assignment의 Id + * @param isAccepted 요청 수락 여부 + * @returns + */ + @Mutation(() => AssignmentPublicizingResponse) + @UseRolesGuard() + async handlePublicizingRequest( + @Args('assignmentId', { type: () => Int }) assignmentId: number, + @Args('isAccepted', ParseBoolPipe) isAccepted: boolean + ) { + return await this.assignmentService.handlePublicizingRequest( + assignmentId, + isAccepted + ) + } + + @Mutation(() => [AssignmentProblem]) + async importProblemsToAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) assignmentId: number, + @Args('problemIdsWithScore', { type: () => [AssignmentProblemScoreInput] }) + problemIdsWithScore: AssignmentProblemScoreInput[] + ) { + return await this.assignmentService.importProblemsToAssignment( + groupId, + assignmentId, + problemIdsWithScore + ) + } + + @Mutation(() => [AssignmentProblem]) + async removeProblemsFromAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) + assignmentId: number, + @Args('problemIds', { type: () => [Int] }) problemIds: number[] + ) { + return await this.assignmentService.removeProblemsFromAssignment( + groupId, + assignmentId, + problemIds + ) + } + + /** + * 특정 User의 Assignment 제출 내용 요약 정보를 가져옵니다. + * + * Assignment Overall 페이지에서 특정 유저를 선택했을 때 사용 + * @see https://github.com/skkuding/codedang/pull/1894 + */ + @Query(() => AssignmentSubmissionSummaryForUser) + async getAssignmentSubmissionSummaryByUserId( + @Args('assignmentId', { type: () => Int }, IDValidationPipe) + assignmentId: number, + @Args('userId', { type: () => Int }, IDValidationPipe) userId: number, + @Args('problemId', { nullable: true, type: () => Int }, IDValidationPipe) + problemId: number, + @Args( + 'take', + { type: () => Int, defaultValue: 10 }, + new RequiredIntPipe('take') + ) + take: number, + @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) + cursor: number | null + ) { + return await this.assignmentService.getAssignmentSubmissionSummaryByUserId( + take, + assignmentId, + userId, + problemId, + cursor + ) + } + + @Mutation(() => DuplicatedAssignmentResponse) + async duplicateAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) + assignmentId: number, + @Context('req') req: AuthenticatedRequest + ) { + return await this.assignmentService.duplicateAssignment( + groupId, + assignmentId, + req.user.id + ) + } + + /** + * Assignment의 참여한 User와, 점수 요약을 함께 불러옵니다. + * + * Assignment Overall 페이지의 Participants 탭의 정보 + * @see https://github.com/skkuding/codedang/pull/2029 + */ + @Query(() => [UserAssignmentScoreSummaryWithUserInfo]) + async getAssignmentScoreSummaries( + @Args( + 'assignmentId', + { type: () => Int, nullable: false }, + IDValidationPipe + ) + assignmentId: number, + @Args('take', { type: () => Int, defaultValue: 10 }) + take: number, + @Args('cursor', { type: () => Int, nullable: true }, CursorValidationPipe) + cursor: number | null, + @Args('searchingName', { type: () => String, nullable: true }) + searchingName?: string + ) { + return await this.assignmentService.getAssignmentScoreSummaries( + assignmentId, + take, + cursor, + searchingName + ) + } + + @Query(() => AssignmentsGroupedByStatus) + async getAssignmentsByProblemId( + @Args('problemId', { type: () => Int }) problemId: number + ) { + return await this.assignmentService.getAssignmentsByProblemId(problemId) + } +} diff --git a/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts b/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts new file mode 100644 index 0000000000..3ce1036514 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts @@ -0,0 +1,494 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Test, type TestingModule } from '@nestjs/testing' +import { AssignmentProblem, Group, AssignmentRecord } from '@generated' +import { Problem } from '@generated' +import { Assignment } from '@generated' +import { faker } from '@faker-js/faker' +import { ResultStatus } from '@prisma/client' +import type { Cache } from 'cache-manager' +import { expect } from 'chai' +import { stub } from 'sinon' +import { EntityNotExistException } from '@libs/exception' +import { PrismaService } from '@libs/prisma' +import { AssignmentService } from './assignment.service' +import type { AssignmentWithParticipants } from './model/assignment-with-participants.model' +import type { + CreateAssignmentInput, + UpdateAssignmentInput +} from './model/assignment.input' +import type { AssignmentPublicizingRequest } from './model/publicizing-request.model' + +const assignmentId = 1 +const userId = 1 +const groupId = 1 +const problemId = 2 +const startTime = faker.date.past() +const endTime = faker.date.future() +const createTime = faker.date.past() +const updateTime = faker.date.past() +const invitationCode = '123456' +const problemIdsWithScore = { + problemId, + score: 10 +} +// const duplicatedAssignmentId = 2 + +const assignment: Assignment = { + id: assignmentId, + createdById: userId, + groupId, + title: 'title', + description: 'description', + startTime, + endTime, + isVisible: true, + isRankVisible: true, + isJudgeResultVisible: true, + enableCopyPaste: true, + createTime, + updateTime, + invitationCode, + assignmentProblem: [] +} + +const assignmentWithCount = { + id: assignmentId, + createdById: userId, + groupId, + title: 'title', + description: 'description', + startTime, + endTime, + isVisible: true, + isRankVisible: true, + isJudgeResultVisible: true, + enableCopyPaste: true, + createTime, + updateTime, + invitationCode, + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + assignmentRecord: 10 + } +} + +const assignmentWithParticipants: AssignmentWithParticipants = { + id: assignmentId, + createdById: userId, + groupId, + title: 'title', + description: 'description', + startTime, + endTime, + isVisible: true, + isRankVisible: true, + enableCopyPaste: true, + isJudgeResultVisible: true, + createTime, + updateTime, + participants: 10, + invitationCode +} + +const group: Group = { + id: groupId, + groupName: 'groupName', + description: 'description', + config: { + showOnList: true, + allowJoinFromSearch: true, + allowJoinWithURL: false, + requireApprovalBeforeJoin: true + }, + createTime: faker.date.past(), + updateTime: faker.date.past() +} + +const problem: Problem = { + id: problemId, + createdById: 2, + groupId: 2, + title: 'test problem', + description: 'thisistestproblem', + inputDescription: 'inputdescription', + outputDescription: 'outputdescription', + hint: 'hint', + template: [], + languages: ['C'], + timeLimit: 10000, + memoryLimit: 100000, + difficulty: 'Level1', + source: 'source', + visibleLockTime: faker.date.past(), + createTime: faker.date.past(), + updateTime: faker.date.past(), + submissionCount: 0, + acceptedCount: 0, + acceptedRate: 0, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null, + engTitle: null +} + +const assignmentProblem: AssignmentProblem = { + order: 0, + assignmentId, + problemId, + score: 50, + createTime: faker.date.past(), + updateTime: faker.date.past() +} + +const submissionsWithProblemTitleAndUsername = { + id: 1, + userId: 1, + userIp: '127.0.0.1', + problemId: 1, + assignmentId: 1, + workbookId: 1, + code: [], + codeSize: 1, + language: 'C', + result: ResultStatus.Accepted, + createTime: '2000-01-01', + updateTime: '2000-01-02', + problem: { + title: 'submission' + }, + user: { + username: 'user01', + studentId: '1234567890' + } +} + +// const submissionResults = [ +// { +// id: 1, +// submissionId: 1, +// problemTestcaseId: 1, +// result: ResultStatus.Accepted, +// cpuTime: BigInt(1), +// memory: 1, +// createTime: '2000-01-01', +// updateTime: '2000-01-02' +// } +// ] + +const publicizingRequest: AssignmentPublicizingRequest = { + assignmentId, + userId, + expireTime: new Date('2050-08-19T07:32:07.533Z') +} + +const input = { + title: 'test title10', + description: 'test description', + startTime: faker.date.past(), + endTime: faker.date.future(), + isVisible: false, + isRankVisible: false, + enableCopyPaste: true, + isJudgeResultVisible: true +} satisfies CreateAssignmentInput + +const updateInput = { + id: 1, + title: 'test title10', + description: 'test description', + startTime: faker.date.past(), + endTime: faker.date.future(), + isVisible: false, + isRankVisible: false, + enableCopyPaste: false +} satisfies UpdateAssignmentInput + +const db = { + assignment: { + findFirst: stub().resolves(Assignment), + findUnique: stub().resolves(Assignment), + findMany: stub().resolves([Assignment]), + create: stub().resolves(Assignment), + update: stub().resolves(Assignment), + delete: stub().resolves(Assignment) + }, + assignmentProblem: { + create: stub().resolves(AssignmentProblem), + findMany: stub().resolves([AssignmentProblem]), + findFirstOrThrow: stub().resolves(AssignmentProblem), + findFirst: stub().resolves(AssignmentProblem) + }, + assignmentRecord: { + findMany: stub().resolves([AssignmentRecord]), + create: stub().resolves(AssignmentRecord) + }, + problem: { + update: stub().resolves(Problem), + updateMany: stub().resolves([Problem]), + findFirstOrThrow: stub().resolves(Problem) + }, + group: { + findUnique: stub().resolves(Group) + }, + submission: { + findMany: stub().resolves([submissionsWithProblemTitleAndUsername]) + }, + // submissionResult: { + // findMany: stub().resolves([submissionResults]) + // }, + $transaction: stub().callsFake(async () => { + const updatedProblem = await db.problem.update() + const newAssignmentProblem = await db.assignmentProblem.create() + return [newAssignmentProblem, updatedProblem] + }), + getPaginator: PrismaService.prototype.getPaginator +} + +describe('AssignmentService', () => { + let service: AssignmentService + let cache: Cache + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssignmentService, + { provide: PrismaService, useValue: db }, + { + provide: CACHE_MANAGER, + useFactory: () => ({ + set: () => [], + get: () => [], + del: () => [], + store: { + keys: () => [] + } + }) + } + ] + }).compile() + + service = module.get(AssignmentService) + cache = module.get(CACHE_MANAGER) + stub(cache.store, 'keys').resolves(['assignment:1:publicize']) + }) + + it('should be defined', () => { + expect(service).to.be.ok + }) + + describe('getAssignments', () => { + it('should return an array of assignments', async () => { + db.assignment.findMany.resolves([assignmentWithCount]) + + const res = await service.getAssignments(5, 2, 0) + expect(res).to.deep.equal([assignmentWithParticipants]) + }) + }) + + describe('getPublicizingRequests', () => { + it('should return an array of PublicizingRequest', async () => { + const cacheSpyGet = stub(cache, 'get').resolves([publicizingRequest]) + const res = await service.getPublicizingRequests() + + expect(cacheSpyGet.called).to.be.true + expect(res).to.deep.equal([publicizingRequest]) + }) + }) + + describe('createAssignment', () => { + it('should return created assignment', async () => { + db.assignment.create.resolves(assignment) + db.group.findUnique.resolves(group) + + const res = await service.createAssignment(groupId, userId, input) + expect(res).to.deep.equal(assignment) + }) + }) + + describe('updateAssignment', () => { + it('should return updated assignment', async () => { + db.assignment.findFirst.resolves(assignment) + db.assignment.update.resolves(assignment) + + const res = await service.updateAssignment(groupId, updateInput) + expect(res).to.deep.equal(assignment) + }) + + it('should throw error when groupId or assignmentId not exist', async () => { + expect(service.updateAssignment(1000, updateInput)).to.be.rejectedWith( + EntityNotExistException + ) + }) + }) + + describe('deleteAssignment', () => { + it('should return deleted assignment', async () => { + db.assignment.findFirst.resolves(assignment) + db.assignment.delete.resolves(assignment) + + const res = await service.deleteAssignment(groupId, assignmentId) + expect(res).to.deep.equal(assignment) + }) + + it('should throw error when groupId or assignmentId not exist', async () => { + expect(service.deleteAssignment(1000, 1000)).to.be.rejectedWith( + EntityNotExistException + ) + }) + }) + + describe('handlePublicizingRequest', () => { + it('should return accepted state', async () => { + db.assignment.update.resolves(assignment) + + const cacheSpyGet = stub(cache, 'get').resolves([publicizingRequest]) + const res = await service.handlePublicizingRequest(assignmentId, true) + + expect(cacheSpyGet.called).to.be.true + expect(res).to.deep.equal({ + assignmentId, + isAccepted: true + }) + }) + + it('should throw error when groupId or assignmentId not exist', async () => { + expect(service.handlePublicizingRequest(1000, true)).to.be.rejectedWith( + EntityNotExistException + ) + }) + + it('should throw error when the assignment is not requested to public', async () => { + expect(service.handlePublicizingRequest(3, true)).to.be.rejectedWith( + EntityNotExistException + ) + }) + }) + + describe('importProblemsToAssignment', () => { + const assignmentWithEmptySubmissions = { + ...assignment, + submission: [] + } + + it('should return created AssignmentProblems', async () => { + db.assignment.findUnique.resolves(assignmentWithEmptySubmissions) + db.problem.update.resolves(problem) + db.assignmentProblem.create.resolves(assignmentProblem) + db.assignmentProblem.findFirst.resolves(null) + + const res = await Promise.all( + await service.importProblemsToAssignment(groupId, assignmentId, [ + problemIdsWithScore + ]) + ) + + expect(res).to.deep.equal([assignmentProblem]) + }) + + it('should return an empty array when the problem already exists in assignment', async () => { + db.assignment.findUnique.resolves(assignmentWithEmptySubmissions) + db.problem.update.resolves(problem) + db.assignmentProblem.findFirst.resolves(AssignmentProblem) + + const res = await service.importProblemsToAssignment( + groupId, + assignmentId, + [problemIdsWithScore] + ) + + expect(res).to.deep.equal([]) + }) + + it('should throw error when the assignmentId not exist', async () => { + expect( + service.importProblemsToAssignment(groupId, 9999, [problemIdsWithScore]) + ).to.be.rejectedWith(EntityNotExistException) + }) + }) + + // describe('getAssignmentSubmissionSummaryByUserId', () => { + // it('should return assignment submission summaries', async () => { + // const res = await service.getAssignmentSubmissionSummaryByUserId(10, 1, 1, 1) + + // expect(res.submissions).to.deep.equal([ + // { + // assignmentId: 1, + // problemTitle: 'submission', + // username: 'user01', + // studentId: '1234567890', + // submissionResult: ResultStatus.Accepted, + // language: 'C', + // submissionTime: '2000-01-01', + // codeSize: 1, + // ip: '127.0.0.1' // TODO: submission.ip 사용 + // } + // ]) + // expect(res.scoreSummary).to.deep.equal({ + // totalProblemCount: 1, + // submittedProblemCount: 1, + // totalScore: 1, + // acceptedTestcaseCountPerProblem: [ + // { + // acceptedTestcaseCount: 0, + // problemId: 1, + // totalTestcaseCount: 1 + // } + // ] + // }) + // }) + // }) + + // describe('duplicateAssignment', () => { + // db['$transaction'] = stub().callsFake(async () => { + // const newAssignment = await db.assignment.create() + // const newAssignmentProblem = await db.assignmentProblem.create() + // const newAssignmentRecord = await db.assignmentRecord.create() + // return [newAssignment, newAssignmentProblem, newAssignmentRecord] + // }) + + // it('should return duplicated assignment', async () => { + // db.assignment.findFirst.resolves(assignment) + // db.assignmentProblem.create.resolves({ + // ...assignment, + // createdById: userId, + // groupId, + // isVisible: false + // }) + // db.assignmentProblem.findMany.resolves([assignmentProblem]) + // db.assignmentProblem.create.resolves({ + // ...assignmentProblem, + // assignmentId: duplicatedAssignmentId + // }) + // db.assignmentRecord.findMany.resolves([assignmentRecord]) + // db.assignmentRecord.create.resolves({ + // ...assignmentRecord, + // assignmentId: duplicatedAssignmentId + // }) + + // const res = await service.duplicateAssignment(groupId, assignmentId, userId) + // expect(res.assignment).to.deep.equal(assignment) + // expect(res.problems).to.deep.equal([ + // { + // ...assignmentProblem, + // assignmentId: duplicatedAssignmentId + // } + // ]) + // expect(res.records).to.deep.equal([ + // { ...assignmentRecord, assignmentId: duplicatedAssignmentId } + // ]) + // }) + + // it('should throw error when the assignmentId not exist', async () => { + // expect( + // service.duplicateAssignment(groupId, 9999, userId) + // ).to.be.rejectedWith(EntityNotExistException) + // }) + + // it('should throw error when the groupId not exist', async () => { + // expect( + // service.duplicateAssignment(9999, assignmentId, userId) + // ).to.be.rejectedWith(EntityNotExistException) + // }) + // }) +}) diff --git a/apps/backend/apps/admin/src/assignment/assignment.service.ts b/apps/backend/apps/admin/src/assignment/assignment.service.ts new file mode 100644 index 0000000000..da26e5152f --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/assignment.service.ts @@ -0,0 +1,973 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { + Inject, + Injectable, + UnprocessableEntityException +} from '@nestjs/common' +import { + Assignment, + ResultStatus, + Submission, + AssignmentProblem +} from '@generated' +import { Cache } from 'cache-manager' +import { + OPEN_SPACE_ID, + PUBLICIZING_REQUEST_EXPIRE_TIME, + PUBLICIZING_REQUEST_KEY, + MIN_DATE, + MAX_DATE +} from '@libs/constants' +import { + ConflictFoundException, + EntityNotExistException, + UnprocessableDataException +} from '@libs/exception' +import { PrismaService } from '@libs/prisma' +import type { AssignmentWithScores } from './model/assignment-with-scores.model' +import type { CreateAssignmentInput } from './model/assignment.input' +import type { UpdateAssignmentInput } from './model/assignment.input' +import type { AssignmentProblemScoreInput } from './model/problem-score.input' +import type { AssignmentPublicizingRequest } from './model/publicizing-request.model' +import type { AssignmentPublicizingResponse } from './model/publicizing-response.output' + +@Injectable() +export class AssignmentService { + constructor( + private readonly prisma: PrismaService, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache + ) {} + + async getAssignments(take: number, groupId: number, cursor: number | null) { + const paginator = this.prisma.getPaginator(cursor) + + const assignments = await this.prisma.assignment.findMany({ + ...paginator, + where: { groupId }, + take, + include: { + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + select: { assignmentRecord: true } + } + } + }) + + return assignments.map((assignment) => { + const { _count, ...data } = assignment + return { + ...data, + participants: _count.assignmentRecord + } + }) + } + + async getAssignment(assignmentId: number) { + const assignment = await this.prisma.assignment.findFirst({ + where: { + id: assignmentId + }, + include: { + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + select: { assignmentRecord: true } + } + } + }) + + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + const { _count, ...data } = assignment + + return { + ...data, + participants: _count.assignmentRecord + } + } + + async createAssignment( + groupId: number, + userId: number, + assignment: CreateAssignmentInput + ): Promise { + if (assignment.startTime >= assignment.endTime) { + throw new UnprocessableDataException( + 'The start time must be earlier than the end time' + ) + } + + const group = await this.prisma.group.findUnique({ + where: { + id: groupId + } + }) + if (!group) { + throw new EntityNotExistException('Group') + } + + try { + return await this.prisma.assignment.create({ + data: { + createdById: userId, + groupId, + ...assignment + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + async updateAssignment( + groupId: number, + assignment: UpdateAssignmentInput + ): Promise { + const assignmentFound = await this.prisma.assignment.findFirst({ + where: { + id: assignment.id, + groupId + }, + select: { + startTime: true, + endTime: true, + assignmentProblem: { + select: { + problemId: true + } + } + } + }) + if (!assignmentFound) { + throw new EntityNotExistException('assignment') + } + const isEndTimeChanged = + assignment.endTime && assignment.endTime !== assignmentFound.endTime + assignment.startTime = assignment.startTime || assignmentFound.startTime + assignment.endTime = assignment.endTime || assignmentFound.endTime + if (assignment.startTime >= assignment.endTime) { + throw new UnprocessableDataException( + 'The start time must be earlier than the end time' + ) + } + + const problemIds = assignmentFound.assignmentProblem.map( + (problem) => problem.problemId + ) + if (problemIds.length && isEndTimeChanged) { + for (const problemId of problemIds) { + try { + // 문제가 포함된 대회 중 가장 늦게 끝나는 대회의 종료시각으로 visibleLockTime 설정 + let visibleLockTime = assignment.endTime + + const assignmentIds = ( + await this.prisma.assignmentProblem.findMany({ + where: { + problemId + } + }) + ) + .filter( + (assignmentProblem) => + assignmentProblem.assignmentId !== assignment.id + ) + .map((assignmentProblem) => assignmentProblem.assignmentId) + + if (assignmentIds.length) { + const latestAssignment = + await this.prisma.assignment.findFirstOrThrow({ + where: { + id: { + in: assignmentIds + } + }, + orderBy: { + endTime: 'desc' + }, + select: { + endTime: true + } + }) + if (assignment.endTime < latestAssignment.endTime) + visibleLockTime = latestAssignment.endTime + } + + await this.prisma.problem.update({ + where: { + id: problemId + }, + data: { + visibleLockTime + } + }) + } catch { + continue + } + } + } + + try { + return await this.prisma.assignment.update({ + where: { + id: assignment.id + }, + data: { + title: assignment.title, + ...assignment + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + async deleteAssignment(groupId: number, assignmentId: number) { + const assignment = await this.prisma.assignment.findFirst({ + where: { + id: assignmentId, + groupId + }, + select: { + assignmentProblem: { + select: { + problemId: true + } + } + } + }) + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + const problemIds = assignment.assignmentProblem.map( + (problem) => problem.problemId + ) + if (problemIds.length) { + await this.removeProblemsFromAssignment(groupId, assignmentId, problemIds) + } + + try { + return await this.prisma.assignment.delete({ + where: { + id: assignmentId + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + async getPublicizingRequests() { + const requests = await this.cacheManager.get< + AssignmentPublicizingRequest[] + >(PUBLICIZING_REQUEST_KEY) + + if (!requests) { + return [] + } + + const filteredRequests = requests.filter( + (req) => new Date(req.expireTime) > new Date() + ) + + if (requests.length != filteredRequests.length) { + await this.cacheManager.set( + PUBLICIZING_REQUEST_KEY, + filteredRequests, + PUBLICIZING_REQUEST_EXPIRE_TIME + ) + } + + return filteredRequests + } + + async handlePublicizingRequest(assignmentId: number, isAccepted: boolean) { + const requests = (await this.cacheManager.get( + PUBLICIZING_REQUEST_KEY + )) as Array + if (!requests) { + throw new EntityNotExistException('AssignmentPublicizingRequest') + } + + const request = requests.find((req) => req.assignmentId === assignmentId) + if (!request || new Date(request.expireTime) < new Date()) { + throw new EntityNotExistException('AssignmentPublicizingRequest') + } + + await this.cacheManager.set( + PUBLICIZING_REQUEST_KEY, + requests.filter((req) => req.assignmentId != assignmentId), + PUBLICIZING_REQUEST_EXPIRE_TIME + ) + + if (isAccepted) { + try { + await this.prisma.assignment.update({ + where: { + id: assignmentId + }, + data: { + groupId: OPEN_SPACE_ID + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + return { + assignmentId, + isAccepted + } as AssignmentPublicizingResponse + } + + async createPublicizingRequest(groupId: number, assignmentId: number) { + if (groupId == OPEN_SPACE_ID) { + throw new UnprocessableEntityException( + 'This assignment is already publicized' + ) + } + + const assignment = await this.prisma.assignment.findFirst({ + where: { + id: assignmentId, + groupId + } + }) + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + let requests = (await this.cacheManager.get( + PUBLICIZING_REQUEST_KEY + )) as Array + if (!requests) { + requests = [] + } + + const duplicatedRequest = requests.find( + (req) => req.assignmentId == assignmentId + ) + if (duplicatedRequest) { + throw new ConflictFoundException('duplicated publicizing request') + } + + const newRequest: AssignmentPublicizingRequest = { + assignmentId, + userId: assignment.createdById!, // TODO: createdById가 null일 경우 예외처리 + expireTime: new Date(Date.now() + PUBLICIZING_REQUEST_EXPIRE_TIME) + } + requests.push(newRequest) + + await this.cacheManager.set( + PUBLICIZING_REQUEST_KEY, + requests, + PUBLICIZING_REQUEST_EXPIRE_TIME + ) + + return newRequest + } + + async importProblemsToAssignment( + groupId: number, + assignmentId: number, + problemIdsWithScore: AssignmentProblemScoreInput[] + ) { + const assignment = await this.prisma.assignment.findUnique({ + where: { + id: assignmentId, + groupId + }, + include: { + submission: { + select: { + id: true + } + } + } + }) + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + const assignmentProblems: AssignmentProblem[] = [] + + for (const { problemId, score } of problemIdsWithScore) { + const isProblemAlreadyImported = + await this.prisma.assignmentProblem.findFirst({ + where: { + assignmentId, + problemId + } + }) + if (isProblemAlreadyImported) { + continue + } + + try { + const [assignmentProblem] = await this.prisma.$transaction([ + this.prisma.assignmentProblem.create({ + data: { + // 원래 id: 'temp'이었는데, assignmentProblem db schema field가 바뀌어서 + // 임시 방편으로 order: 0으로 설정합니다. + order: 0, + assignmentId, + problemId, + score + } + }), + this.prisma.problem.updateMany({ + where: { + id: problemId, + OR: [ + { + visibleLockTime: { + equals: MIN_DATE + } + }, + { + visibleLockTime: { + equals: MAX_DATE + } + }, + { + visibleLockTime: { + lte: assignment.endTime + } + } + ] + }, + data: { + visibleLockTime: assignment.endTime + } + }) + ]) + assignmentProblems.push(assignmentProblem) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + return assignmentProblems + } + + async removeProblemsFromAssignment( + groupId: number, + assignmentId: number, + problemIds: number[] + ) { + const assignment = await this.prisma.assignment.findUnique({ + where: { + id: assignmentId, + groupId + }, + include: { + submission: { + select: { + id: true + } + } + } + }) + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + const assignmentProblems: AssignmentProblem[] = [] + + for (const problemId of problemIds) { + // 문제가 포함된 대회 중 가장 늦게 끝나는 대회의 종료시각으로 visibleLockTime 설정 (없을시 비공개 전환) + let visibleLockTime = MAX_DATE + + const assignmentIds = ( + await this.prisma.assignmentProblem.findMany({ + where: { + problemId + } + }) + ) + .filter( + (assignmentProblem) => assignmentProblem.assignmentId !== assignmentId + ) + .map((assignmentProblem) => assignmentProblem.assignmentId) + + if (assignmentIds.length) { + const latestAssignment = await this.prisma.assignment.findFirst({ + where: { + id: { + in: assignmentIds + } + }, + orderBy: { + endTime: 'desc' + }, + select: { + endTime: true + } + }) + + if (!latestAssignment) { + throw new EntityNotExistException('assignment') + } + + visibleLockTime = latestAssignment.endTime + } + + try { + const [, assignmentProblem] = await this.prisma.$transaction([ + this.prisma.problem.updateMany({ + where: { + id: problemId, + visibleLockTime: { + lte: assignment.endTime + } + }, + data: { + visibleLockTime + } + }), + this.prisma.assignmentProblem.delete({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assignmentId_problemId: { + assignmentId, + problemId + } + } + }) + ]) + + assignmentProblems.push(assignmentProblem) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + return assignmentProblems + } + + async getAssignmentSubmissionSummaryByUserId( + take: number, + assignmentId: number, + userId: number, + problemId: number | null, + cursor: number | null + ) { + const paginator = this.prisma.getPaginator(cursor) + const submissions = await this.prisma.submission.findMany({ + ...paginator, + take, + where: { + userId, + assignmentId, + problemId: problemId ?? undefined + }, + include: { + problem: { + select: { + title: true, + assignmentProblem: { + where: { + assignmentId, + problemId: problemId ?? undefined + } + } + } + }, + user: { + select: { + username: true, + studentId: true + } + } + } + }) + + const mappedSubmission = submissions.map((submission) => { + return { + assignmentId: submission.assignmentId, + problemTitle: submission.problem.title, + username: submission.user?.username, + studentId: submission.user?.studentId, + submissionResult: submission.result, + language: submission.language, + submissionTime: submission.createTime, + codeSize: submission.codeSize, + ip: submission.userIp, + id: submission.id, + problemId: submission.problemId, + order: submission.problem.assignmentProblem.length + ? submission.problem.assignmentProblem[0].order + : null + } + }) + + const scoreSummary = await this.getAssignmentScoreSummary( + userId, + assignmentId + ) + + return { + scoreSummary, + submissions: mappedSubmission + } + } + + /** + * Duplicate assignment with assignment problems and users who participated in the assignment + * Not copied: submission + * @param groupId group to duplicate assignment + * @param assignmentId assignment to duplicate + * @param userId user who tries to duplicates the assignment + * @returns + */ + async duplicateAssignment( + groupId: number, + assignmentId: number, + userId: number + ) { + const [assignmentFound, assignmentProblemsFound, userAssignmentRecords] = + await Promise.all([ + this.prisma.assignment.findFirst({ + where: { + id: assignmentId, + groupId + } + }), + this.prisma.assignmentProblem.findMany({ + where: { + assignmentId + } + }), + this.prisma.assignmentRecord.findMany({ + where: { + assignmentId + } + }) + ]) + + if (!assignmentFound) { + throw new EntityNotExistException('assignment') + } + + // if assignment status is ongoing, visible would be true. else, false + const now = new Date() + let newVisible = false + if (assignmentFound.startTime <= now && now <= assignmentFound.endTime) { + newVisible = true + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, createTime, updateTime, title, ...assignmentDataToCopy } = + assignmentFound + + try { + const [newAssignment, newAssignmentProblems, newAssignmentRecords] = + await this.prisma.$transaction(async (tx) => { + // 1. copy assignment + const newAssignment = await tx.assignment.create({ + data: { + ...assignmentDataToCopy, + title: 'Copy of ' + title, + createdById: userId, + groupId, + isVisible: newVisible + } + }) + + // 2. copy assignment problems + const newAssignmentProblems = await Promise.all( + assignmentProblemsFound.map((assignmentProblem) => + tx.assignmentProblem.create({ + data: { + order: assignmentProblem.order, + assignmentId: newAssignment.id, + problemId: assignmentProblem.problemId, + score: assignmentProblem.score + } + }) + ) + ) + + // 3. copy assignment records (users who participated in the assignment) + const newAssignmentRecords = await Promise.all( + userAssignmentRecords.map((userAssignmentRecord) => + tx.assignmentRecord.create({ + data: { + assignmentId: newAssignment.id, + userId: userAssignmentRecord.userId + } + }) + ) + ) + + return [newAssignment, newAssignmentProblems, newAssignmentRecords] + }) + + return { + assignment: newAssignment, + problems: newAssignmentProblems, + records: newAssignmentRecords + } + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + /** + * 특정 user의 특정 Assignment에 대한 총점, 통과한 문제 개수와 각 문제별 테스트케이스 통과 개수를 불러옵니다. + */ + async getAssignmentScoreSummary(userId: number, assignmentId: number) { + const [assignmentProblems, rawSubmissions] = await Promise.all([ + this.prisma.assignmentProblem.findMany({ + where: { + assignmentId + } + }), + this.prisma.submission.findMany({ + where: { + userId, + assignmentId + }, + orderBy: { + createTime: 'desc' + } + }) + ]) + + // 오직 현재 Assignment에 남아있는 문제들의 제출에 대해서만 ScoreSummary 계산 + const assignmentProblemIds = assignmentProblems.map( + (assignmentProblem) => assignmentProblem.problemId + ) + const submissions = rawSubmissions.filter((submission) => + assignmentProblemIds.includes(submission.problemId) + ) + + if (!submissions.length) { + return { + submittedProblemCount: 0, + totalProblemCount: assignmentProblems.length, + userAssignmentScore: 0, + assignmentPerfectScore: assignmentProblems.reduce( + (total, { score }) => total + score, + 0 + ), + problemScores: [] + } + } + + // 하나의 Problem에 대해 여러 개의 Submission이 존재한다면, 마지막에 제출된 Submission만을 점수 계산에 반영함 + const latestSubmissions: { + [problemId: string]: { + result: ResultStatus + score: number // Problem에서 획득한 점수 (maxScore 만점 기준) + maxScore: number // Assignment에서 Problem이 갖는 배점(만점) + } + } = await this.getlatestSubmissions(submissions) + + const problemScores: { + problemId: number + score: number + maxScore: number + }[] = [] + + for (const problemId in latestSubmissions) { + problemScores.push({ + problemId: parseInt(problemId), + score: latestSubmissions[problemId].score, + maxScore: latestSubmissions[problemId].maxScore + }) + } + + const scoreSummary = { + submittedProblemCount: Object.keys(latestSubmissions).length, // Assignment에 존재하는 문제 중 제출된 문제의 개수 + totalProblemCount: assignmentProblems.length, // Assignment에 존재하는 Problem의 총 개수 + userAssignmentScore: problemScores.reduce( + (total, { score }) => total + score, + 0 + ), // Assignment에서 유저가 받은 점수 + assignmentPerfectScore: assignmentProblems.reduce( + (total, { score }) => total + score, + 0 + ), // Assignment의 만점 + problemScores // 개별 Problem의 점수 리스트 (각 문제에서 몇 점을 획득했는지) + } + + return scoreSummary + } + + async getlatestSubmissions(submissions: Submission[]) { + const latestSubmissions: { + [problemId: string]: { + result: ResultStatus + score: number // Problem에서 획득한 점수 + maxScore: number // Assignment에서 Problem이 갖는 배점 + } + } = {} + + for (const submission of submissions) { + const problemId = submission.problemId + if (problemId in latestSubmissions) continue + + const assignmentProblem = await this.prisma.assignmentProblem.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assignmentId_problemId: { + assignmentId: submission.assignmentId!, + problemId: submission.problemId + } + }, + select: { + score: true + } + }) + + if (!assignmentProblem) { + throw new EntityNotExistException('assignmentProblem') + } + + const maxScore = assignmentProblem.score + + latestSubmissions[problemId] = { + result: submission.result as ResultStatus, + score: (submission.score / 100) * maxScore, // assignment에 할당된 Problem의 총점에 맞게 계산 + maxScore + } + } + + return latestSubmissions + } + + async getAssignmentScoreSummaries( + assignmentId: number, + take: number, + cursor: number | null, + searchingName?: string + ) { + const paginator = this.prisma.getPaginator(cursor) + + const assignmentRecords = await this.prisma.assignmentRecord.findMany({ + ...paginator, + where: { + assignmentId, + userId: { + not: null + }, + user: searchingName + ? { + userProfile: { + realName: { + contains: searchingName, + mode: 'insensitive' + } + } + } + : undefined + }, + take, + include: { + user: { + select: { + username: true, + studentId: true, + userProfile: { + select: { + realName: true + } + }, + major: true + } + } + }, + orderBy: { + user: { + studentId: 'asc' + } + } + }) + + const assignmentRecordsWithScoreSummary = await Promise.all( + assignmentRecords.map(async (record) => { + return { + userId: record.userId, + username: record.user?.username, + studentId: record.user?.studentId, + realName: record.user?.userProfile?.realName, + major: record.user?.major, + ...(await this.getAssignmentScoreSummary( + record.userId as number, + assignmentId + )) + } + }) + ) + + return assignmentRecordsWithScoreSummary + } + + async getAssignmentsByProblemId(problemId: number) { + const assignmentProblems = await this.prisma.assignmentProblem.findMany({ + where: { + problemId + }, + select: { + assignment: true, + score: true + } + }) + + const assignments = await Promise.all( + assignmentProblems.map(async (assignmentProblem) => { + return { + ...assignmentProblem.assignment, + problemScore: assignmentProblem.score, + totalScore: await this.getTotalScoreOfAssignment( + assignmentProblem.assignment.id + ) + } + }) + ) + + const now = new Date() + + const assignmentsGroupedByStatus = assignments.reduce( + (acc, assignment) => { + if (assignment.endTime > now) { + if (assignment.startTime <= now) { + acc.ongoing.push(assignment) + } else { + acc.upcoming.push(assignment) + } + } else { + acc.finished.push(assignment) + } + return acc + }, + { + upcoming: [] as AssignmentWithScores[], + ongoing: [] as AssignmentWithScores[], + finished: [] as AssignmentWithScores[] + } + ) + + return assignmentsGroupedByStatus + } + + async getTotalScoreOfAssignment(assignmentId: number) { + const assignmentProblemScores = + await this.prisma.assignmentProblem.findMany({ + where: { + assignmentId + }, + select: { + score: true + } + }) + + return assignmentProblemScores.reduce( + (total, problem) => total + problem.score, + 0 + ) + } +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignment-submission-summary-for-user.model.ts b/apps/backend/apps/admin/src/assignment/model/assignment-submission-summary-for-user.model.ts new file mode 100644 index 0000000000..4468609f5e --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignment-submission-summary-for-user.model.ts @@ -0,0 +1,54 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Language, ResultStatus } from '@admin/@generated' +import { UserAssignmentScoreSummary } from './score-summary' + +@ObjectType({ description: 'AssignmentSubmissionSummaryForUser' }) +export class AssignmentSubmissionSummaryForUser { + @Field(() => UserAssignmentScoreSummary, { nullable: false }) + scoreSummary: UserAssignmentScoreSummary + + @Field(() => [AssignmentSubmissionSummaryForOne], { nullable: false }) + submissions: AssignmentSubmissionSummaryForOne[] +} + +/** + * 특정 User의 특정 Assignment에 대한 Submission 정보 (!== model SubmissionResult) + */ +@ObjectType({ description: 'AssignmentSubmissionSummaryForOne' }) +export class AssignmentSubmissionSummaryForOne { + @Field(() => Int, { nullable: false }) + assignmentId: number + + @Field(() => String, { nullable: false }) + problemTitle: string + + @Field(() => String, { nullable: false }) + username: string + + @Field(() => String, { nullable: false }) + studentId: string + + @Field(() => ResultStatus, { nullable: false }) + submissionResult: ResultStatus // Accepted, RuntimeError, ... + + @Field(() => Language, { nullable: false }) + language: Language + + @Field(() => String, { nullable: false }) + submissionTime: Date + + @Field(() => Int, { nullable: true }) + codeSize?: number + + @Field(() => String, { nullable: true }) + ip?: string + + @Field(() => Int, { nullable: false }) + id: number + + @Field(() => Int, { nullable: false }) + problemId: number + + @Field(() => Int, { nullable: true }) + order: number | null +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignment-with-participants.model.ts b/apps/backend/apps/admin/src/assignment/model/assignment-with-participants.model.ts new file mode 100644 index 0000000000..a3e1a85933 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignment-with-participants.model.ts @@ -0,0 +1,8 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Assignment } from '@admin/@generated' + +@ObjectType({ description: 'assignmentWithParticipants' }) +export class AssignmentWithParticipants extends Assignment { + @Field(() => Int) + participants: number +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignment-with-scores.model.ts b/apps/backend/apps/admin/src/assignment/model/assignment-with-scores.model.ts new file mode 100644 index 0000000000..9baa2b1ee5 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignment-with-scores.model.ts @@ -0,0 +1,11 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Assignment } from '@admin/@generated' + +@ObjectType() +export class AssignmentWithScores extends Assignment { + @Field(() => Int) + problemScore: number + + @Field(() => Int) + totalScore: number +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignment.input.ts b/apps/backend/apps/admin/src/assignment/model/assignment.input.ts new file mode 100644 index 0000000000..633e5a8a40 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignment.input.ts @@ -0,0 +1,71 @@ +import { Field, GraphQLISODateTime, InputType, Int } from '@nestjs/graphql' +import { IsNumberString, IsOptional, Length } from 'class-validator' + +@InputType() +export class CreateAssignmentInput { + @Field(() => String, { nullable: false }) + title!: string + + @Field(() => String, { nullable: false }) + description!: string + + @IsOptional() + @IsNumberString() + @Length(6, 6) + @Field(() => String, { nullable: true }) + invitationCode?: string + + @Field(() => GraphQLISODateTime, { nullable: false }) + startTime!: Date + + @Field(() => GraphQLISODateTime, { nullable: false }) + endTime!: Date + + @Field(() => Boolean, { nullable: false }) + isVisible!: boolean + + @Field(() => Boolean, { nullable: false }) + isRankVisible!: boolean + + @Field(() => Boolean, { nullable: false }) + isJudgeResultVisible!: boolean + + @Field(() => Boolean, { nullable: true }) + enableCopyPaste?: boolean +} + +@InputType() +export class UpdateAssignmentInput { + @Field(() => Int, { nullable: false }) + id!: number + + @Field(() => String, { nullable: true }) + title?: string + + @Field(() => String, { nullable: true }) + description?: string + + @IsOptional() + @IsNumberString() + @Length(6, 6) + @Field(() => String, { nullable: true }) + invitationCode?: string + + @Field(() => GraphQLISODateTime, { nullable: true }) + startTime?: Date + + @Field(() => GraphQLISODateTime, { nullable: true }) + endTime?: Date + + @Field(() => Boolean, { nullable: true }) + isVisible?: boolean + + @Field(() => Boolean, { nullable: true }) + isRankVisible?: boolean + + @Field(() => Boolean, { nullable: true }) + enableCopyPaste?: boolean + + @Field(() => Boolean, { nullable: true }) + isJudgeResultVisible?: boolean +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignments-grouped-by-status.output.ts b/apps/backend/apps/admin/src/assignment/model/assignments-grouped-by-status.output.ts new file mode 100644 index 0000000000..7fdab246e8 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignments-grouped-by-status.output.ts @@ -0,0 +1,14 @@ +import { Field, ObjectType } from '@nestjs/graphql' +import { AssignmentWithScores } from './assignment-with-scores.model' + +@ObjectType() +export class AssignmentsGroupedByStatus { + @Field(() => [AssignmentWithScores]) + upcoming: AssignmentWithScores[] + + @Field(() => [AssignmentWithScores]) + ongoing: AssignmentWithScores[] + + @Field(() => [AssignmentWithScores]) + finished: AssignmentWithScores[] +} diff --git a/apps/backend/apps/admin/src/assignment/model/duplicated-assignment-response.output.ts b/apps/backend/apps/admin/src/assignment/model/duplicated-assignment-response.output.ts new file mode 100644 index 0000000000..ef2463b817 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/duplicated-assignment-response.output.ts @@ -0,0 +1,20 @@ +import { Field, ObjectType } from '@nestjs/graphql' +import { Type } from 'class-transformer' +import { + Assignment, + AssignmentProblem, + AssignmentRecord +} from '@admin/@generated' + +@ObjectType() +export class DuplicatedAssignmentResponse { + @Field(() => Assignment) + assignment: Assignment + + @Field(() => [AssignmentProblem]) + problems: AssignmentProblem[] + + @Field(() => [AssignmentRecord]) + @Type(() => AssignmentRecord) + records: AssignmentRecord[] +} diff --git a/apps/backend/apps/admin/src/assignment/model/problem-score.input.ts b/apps/backend/apps/admin/src/assignment/model/problem-score.input.ts new file mode 100644 index 0000000000..b46557bee4 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/problem-score.input.ts @@ -0,0 +1,11 @@ +import { Field, InputType, Int } from '@nestjs/graphql' +import { IntScoreScalar } from '@admin/problem/scalar/int-score.scalar' + +@InputType() +export class AssignmentProblemScoreInput { + @Field(() => Int) + problemId: number + + @Field(() => IntScoreScalar) + score: number +} diff --git a/apps/backend/apps/admin/src/assignment/model/publicizing-request.model.ts b/apps/backend/apps/admin/src/assignment/model/publicizing-request.model.ts new file mode 100644 index 0000000000..6b96e98d50 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/publicizing-request.model.ts @@ -0,0 +1,13 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' + +@ObjectType() +export class AssignmentPublicizingRequest { + @Field(() => Int) + assignmentId: number + + @Field(() => Int) + userId: number + + @Field(() => String) + expireTime: Date +} diff --git a/apps/backend/apps/admin/src/assignment/model/publicizing-response.output.ts b/apps/backend/apps/admin/src/assignment/model/publicizing-response.output.ts new file mode 100644 index 0000000000..0b493fa8fe --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/publicizing-response.output.ts @@ -0,0 +1,10 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' + +@ObjectType() +export class AssignmentPublicizingResponse { + @Field(() => Int) + assignmentId: number + + @Field(() => Boolean) + isAccepted: boolean +} diff --git a/apps/backend/apps/admin/src/assignment/model/score-summary.ts b/apps/backend/apps/admin/src/assignment/model/score-summary.ts new file mode 100644 index 0000000000..fdf50443c7 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/score-summary.ts @@ -0,0 +1,64 @@ +import { Field, Float, Int, ObjectType } from '@nestjs/graphql' + +@ObjectType() +export class UserAssignmentScoreSummary { + @Field(() => Int) + submittedProblemCount: number + + @Field(() => Int) + totalProblemCount: number + + @Field(() => Float) + userAssignmentScore: number + + @Field(() => Int) + assignmentPerfectScore: number + + @Field(() => [AssignmentProblemScore]) + problemScores: AssignmentProblemScore[] +} + +@ObjectType() +export class UserAssignmentScoreSummaryWithUserInfo { + @Field(() => Int) + userId: number + + @Field(() => String) + username: string + + @Field(() => String) + studentId: string + + @Field(() => String, { nullable: true }) + realName: string + + @Field(() => String) + major: string + + @Field(() => Int) + submittedProblemCount: number + + @Field(() => Int) + totalProblemCount: number + + @Field(() => Float) + userAssignmentScore: number + + @Field(() => Int) + assignmentPerfectScore: number + + @Field(() => [AssignmentProblemScore]) + problemScores: AssignmentProblemScore[] +} + +@ObjectType() +class AssignmentProblemScore { + @Field(() => Int) + problemId: number + + @Field(() => Float) + score: number + + @Field(() => Int) + maxScore: number +} diff --git a/apps/backend/apps/client/src/announcement/announcement.controller.ts b/apps/backend/apps/client/src/announcement/announcement.controller.ts index d53d21a0b9..82038e4885 100644 --- a/apps/backend/apps/client/src/announcement/announcement.controller.ts +++ b/apps/backend/apps/client/src/announcement/announcement.controller.ts @@ -6,8 +6,11 @@ import { Query } from '@nestjs/common' import { AuthNotNeededIfOpenSpace } from '@libs/auth' -import { EntityNotExistException } from '@libs/exception' -import { GroupIDPipe, IDValidationPipe, RequiredIntPipe } from '@libs/pipe' +import { + EntityNotExistException, + UnprocessableDataException +} from '@libs/exception' +import { GroupIDPipe, IDValidationPipe } from '@libs/pipe' import { AnnouncementService } from './announcement.service' @Controller('announcement') @@ -20,24 +23,42 @@ export class AnnouncementController { @Get() async getAnnouncements( @Query('problemId', IDValidationPipe) problemId: number | null, - @Query('contestId', RequiredIntPipe) contestId: number, - @Query('groupId', GroupIDPipe) groupId: number + @Query('contestId', IDValidationPipe) contestId: number | null, + @Query('assignmentId', IDValidationPipe) assignmentId: number | null, + @Query('groupId', GroupIDPipe) + groupId: number ) { try { + if (!!contestId === !!assignmentId) { + throw new UnprocessableDataException( + 'Either contestId or assignmentId must be provided, but not both.' + ) + } if (problemId) { return await this.announcementService.getProblemAnnouncements( - problemId, contestId, + assignmentId, + problemId, groupId ) } else { - return await this.announcementService.getContestAnnouncements( - contestId, - groupId - ) + if (contestId) { + return await this.announcementService.getContestAnnouncements( + contestId, + groupId + ) + } else { + return await this.announcementService.getAssignmentAnnouncements( + assignmentId!, + groupId + ) + } } } catch (error) { - if (error instanceof EntityNotExistException) { + if ( + error instanceof EntityNotExistException || + error instanceof UnprocessableDataException + ) { throw error.convert2HTTPException() } this.logger.error(error) diff --git a/apps/backend/apps/client/src/announcement/announcement.service.spec.ts b/apps/backend/apps/client/src/announcement/announcement.service.spec.ts index c841456a51..be12cf43fb 100644 --- a/apps/backend/apps/client/src/announcement/announcement.service.spec.ts +++ b/apps/backend/apps/client/src/announcement/announcement.service.spec.ts @@ -54,12 +54,13 @@ describe('AnnouncementService', () => { describe('getProblemAnnouncements', () => { it('should return problem announcements', async () => { - const res = await service.getProblemAnnouncements(1, 1, 1) + const res = await service.getProblemAnnouncements(1, null, 1, 1) expect(res) .excluding(['createTime', 'updateTime', 'content']) .to.deep.equal([ { - id: 6, + id: 16, + assignmentId: null, contestId: 1, problemId: 1 } @@ -74,16 +75,40 @@ describe('AnnouncementService', () => { .excluding(['createTime', 'updateTime', 'content']) .to.deep.equal([ { - id: 6, + id: 16, + assignmentId: null, contestId: 1, problemId: 0 }, { - id: 1, + id: 11, + assignmentId: null, contestId: 1, problemId: null } ]) }) }) + + describe('getAssignmentAnnouncements', () => { + it('should return multiple assignment announcements', async () => { + const res = await service.getAssignmentAnnouncements(1, 1) + expect(res) + .excluding(['createTime', 'updateTime', 'content']) + .to.deep.equal([ + { + id: 6, + assignmentId: 1, + contestId: null, + problemId: 0 + }, + { + id: 1, + assignmentId: 1, + contestId: null, + problemId: null + } + ]) + }) + }) }) diff --git a/apps/backend/apps/client/src/announcement/announcement.service.ts b/apps/backend/apps/client/src/announcement/announcement.service.ts index ec1e325dab..3f4818c707 100644 --- a/apps/backend/apps/client/src/announcement/announcement.service.ts +++ b/apps/backend/apps/client/src/announcement/announcement.service.ts @@ -34,20 +34,62 @@ export class AnnouncementService { }) } - async getProblemAnnouncements( - contestId: number, - problemId: number, + async getAssignmentAnnouncements( + assignmentId: number, groupId: number ): Promise { - return await this.prisma.announcement.findMany({ - where: { - problemId, - contest: { - id: contestId, + const { assignmentProblem, announcement } = + await this.prisma.assignment.findUniqueOrThrow({ + where: { + id: assignmentId, groupId + }, + select: { + assignmentProblem: true, + announcement: { + orderBy: { updateTime: 'desc' } + } } - }, - orderBy: { updateTime: 'desc' } + }) + + return announcement.map((announcement) => { + if (announcement.problemId !== null) { + announcement.problemId = assignmentProblem.find( + (problem) => announcement.problemId === problem.problemId + )!.order + } + return announcement }) } + + async getProblemAnnouncements( + contestId: number | null, + assignmentId: number | null, + problemId: number, + groupId: number + ): Promise { + if (contestId) { + return await this.prisma.announcement.findMany({ + where: { + problemId, + contest: { + id: contestId, + groupId + } + }, + orderBy: { updateTime: 'desc' } + }) + } else { + return await this.prisma.announcement.findMany({ + where: { + problemId, + assignment: { + id: assignmentId!, + groupId + } + }, + orderBy: { updateTime: 'desc' } + }) + } + } } diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index d418fd7524..9239969ec3 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -15,6 +15,7 @@ import { StorageModule } from '@libs/storage' import { AnnouncementModule } from './announcement/announcement.module' import { AppController } from './app.controller' import { AppService } from './app.service' +import { AssignmentModule } from './assignment/assignment.module' import { AuthModule } from './auth/auth.module' import { ContestModule } from './contest/contest.module' import { EmailModule } from './email/email.module' @@ -49,6 +50,7 @@ import { WorkbookModule } from './workbook/workbook.module' EmailModule, AnnouncementModule, StorageModule, + AssignmentModule, LoggerModule.forRoot(pinoLoggerModuleOption), OpenTelemetryModule.forRoot() ], diff --git a/apps/backend/apps/client/src/assignment/assignment.controller.spec.ts b/apps/backend/apps/client/src/assignment/assignment.controller.spec.ts new file mode 100644 index 0000000000..78a0a9c63c --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.controller.spec.ts @@ -0,0 +1,25 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { expect } from 'chai' +import { RolesService } from '@libs/auth' +import { AssignmentController } from './assignment.controller' +import { AssignmentService } from './assignment.service' + +describe('AssignmentController', () => { + let controller: AssignmentController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AssignmentController], + providers: [ + { provide: AssignmentService, useValue: {} }, + { provide: RolesService, useValue: {} } + ] + }).compile() + + controller = module.get(AssignmentController) + }) + + it('should be defined', () => { + expect(controller).to.be.ok + }) +}) diff --git a/apps/backend/apps/client/src/assignment/assignment.controller.ts b/apps/backend/apps/client/src/assignment/assignment.controller.ts new file mode 100644 index 0000000000..90a9463b14 --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.controller.ts @@ -0,0 +1,207 @@ +import { + Controller, + InternalServerErrorException, + NotFoundException, + Param, + Post, + Req, + Get, + Query, + Logger, + DefaultValuePipe, + Delete +} from '@nestjs/common' +import { Prisma } from '@prisma/client' +import { + AuthNotNeededIfOpenSpace, + AuthenticatedRequest, + UserNullWhenAuthFailedIfOpenSpace +} from '@libs/auth' +import { + ConflictFoundException, + EntityNotExistException, + ForbiddenAccessException +} from '@libs/exception' +import { + CursorValidationPipe, + GroupIDPipe, + IDValidationPipe, + RequiredIntPipe +} from '@libs/pipe' +import { AssignmentService } from './assignment.service' + +@Controller('assignment') +export class AssignmentController { + private readonly logger = new Logger(AssignmentController.name) + + constructor(private readonly assignmentService: AssignmentService) {} + + @Get('ongoing-upcoming') + @AuthNotNeededIfOpenSpace() + async getOngoingUpcomingAssignments( + @Query('groupId', GroupIDPipe) groupId: number + ) { + try { + return await this.assignmentService.getAssignmentsByGroupId(groupId) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('ongoing-upcoming-with-registered') + async getOngoingUpcomingAssignmentsWithRegistered( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number + ) { + try { + return await this.assignmentService.getAssignmentsByGroupId( + groupId, + req.user.id + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('finished') + @UserNullWhenAuthFailedIfOpenSpace() + async getFinishedAssignments( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Query('cursor', CursorValidationPipe) cursor: number | null, + @Query('take', new DefaultValuePipe(10), new RequiredIntPipe('take')) + take: number, + @Query('search') search?: string + ) { + try { + return await this.assignmentService.getFinishedAssignmentsByGroupId( + req.user?.id, + cursor, + take, + groupId, + search + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('registered-finished') + async getRegisteredFinishedAssignments( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Query('cursor', CursorValidationPipe) cursor: number | null, + @Query('take', new DefaultValuePipe(10), new RequiredIntPipe('take')) + take: number, + @Query('search') search?: string + ) { + try { + return await this.assignmentService.getRegisteredFinishedAssignments( + cursor, + take, + groupId, + req.user.id, + search + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('registered-ongoing-upcoming') + async getRegisteredOngoingUpcomingAssignments( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Query('search') search?: string + ) { + try { + return await this.assignmentService.getRegisteredOngoingUpcomingAssignments( + groupId, + req.user.id, + search + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get(':id') + @UserNullWhenAuthFailedIfOpenSpace() + async getAssignment( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Param('id', new RequiredIntPipe('id')) id: number + ) { + try { + return await this.assignmentService.getAssignment( + id, + groupId, + req.user?.id + ) + } catch (error) { + if (error instanceof EntityNotExistException) { + throw error.convert2HTTPException() + } + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Post(':id/participation') + async createAssignmentRecord( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Param('id', IDValidationPipe) assignmentId: number, + @Query('invitationCode') invitationCode?: string + ) { + try { + return await this.assignmentService.createAssignmentRecord( + assignmentId, + req.user.id, + invitationCode, + groupId + ) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.name === 'NotFoundError' + ) { + throw new NotFoundException(error.message) + } else if (error instanceof ConflictFoundException) { + throw error.convert2HTTPException() + } + this.logger.error(error) + throw new InternalServerErrorException(error.message) + } + } + + // unregister only for upcoming Assignment + @Delete(':id/participation') + async deleteAssignmentRecord( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Param('id', IDValidationPipe) assignmentId: number + ) { + try { + return await this.assignmentService.deleteAssignmentRecord( + assignmentId, + req.user.id, + groupId + ) + } catch (error) { + if ( + error instanceof ForbiddenAccessException || + error instanceof EntityNotExistException + ) { + throw error.convert2HTTPException() + } + this.logger.error(error) + throw new InternalServerErrorException(error.message) + } + } +} diff --git a/apps/backend/apps/client/src/assignment/assignment.module.ts b/apps/backend/apps/client/src/assignment/assignment.module.ts new file mode 100644 index 0000000000..915ac4dc41 --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { APP_GUARD } from '@nestjs/core' +import { GroupMemberGuard, RolesModule } from '@libs/auth' +import { AssignmentController } from './assignment.controller' +import { AssignmentService } from './assignment.service' + +@Module({ + imports: [RolesModule], + controllers: [AssignmentController], + providers: [ + AssignmentService, + { provide: APP_GUARD, useClass: GroupMemberGuard } + ], + exports: [AssignmentService] +}) +export class AssignmentModule {} diff --git a/apps/backend/apps/client/src/assignment/assignment.service.spec.ts b/apps/backend/apps/client/src/assignment/assignment.service.spec.ts new file mode 100644 index 0000000000..1e1361ccc4 --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.service.spec.ts @@ -0,0 +1,473 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { ConfigService } from '@nestjs/config' +import { Test, type TestingModule } from '@nestjs/testing' +import { + Prisma, + type Assignment, + type Group, + type AssignmentRecord +} from '@prisma/client' +import { expect } from 'chai' +import * as dayjs from 'dayjs' +import { + ConflictFoundException, + EntityNotExistException, + ForbiddenAccessException +} from '@libs/exception' +import { + PrismaService, + PrismaTestService, + type FlatTransactionClient +} from '@libs/prisma' +import { AssignmentService, type AssignmentResult } from './assignment.service' + +const assignmentId = 1 +const user01Id = 4 +const groupId = 1 + +const now = dayjs() + +const assignment = { + id: assignmentId, + createdById: 1, + groupId, + title: 'title', + description: 'description', + startTime: now.add(-1, 'day').toDate(), + endTime: now.add(1, 'day').toDate(), + isVisible: true, + isJudgeResultVisible: true, + isRankVisible: true, + enableCopyPaste: true, + createTime: now.add(-1, 'day').toDate(), + updateTime: now.add(-1, 'day').toDate(), + group: { + id: groupId, + groupName: 'group' + }, + invitationCode: '123456' +} satisfies Assignment & { + group: Partial +} + +const ongoingAssignments = [ + { + id: assignment.id, + group: assignment.group, + title: assignment.title, + invitationCode: 'test', + isJudgeResultVisible: true, + startTime: now.add(-1, 'day').toDate(), + endTime: now.add(1, 'day').toDate(), + participants: 1, + enableCopyPaste: true + } +] satisfies Partial[] + +const upcomingAssignments = [ + { + id: assignment.id + 6, + group: assignment.group, + title: assignment.title, + invitationCode: 'test', + isJudgeResultVisible: true, + startTime: now.add(1, 'day').toDate(), + endTime: now.add(2, 'day').toDate(), + participants: 1, + enableCopyPaste: true + } +] satisfies Partial[] + +const finishedAssignments = [ + { + id: assignment.id + 1, + group: assignment.group, + title: assignment.title, + invitationCode: null, + isJudgeResultVisible: true, + startTime: now.add(-2, 'day').toDate(), + endTime: now.add(-1, 'day').toDate(), + participants: 1, + enableCopyPaste: true + } +] satisfies Partial[] + +const assignments = [ + ...ongoingAssignments, + ...finishedAssignments, + ...upcomingAssignments +] satisfies Partial[] + +describe('AssignmentService', () => { + let service: AssignmentService + let prisma: PrismaTestService + let transaction: FlatTransactionClient + + before(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssignmentService, + PrismaTestService, + { + provide: PrismaService, + useExisting: PrismaTestService + }, + ConfigService, + { + provide: CACHE_MANAGER, + useFactory: () => ({ + set: () => [], + get: () => [] + }) + } + ] + }).compile() + service = module.get(AssignmentService) + prisma = module.get(PrismaTestService) + }) + + beforeEach(async () => { + transaction = await prisma.$begin() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(service as any).prisma = transaction + }) + + afterEach(async () => { + await transaction.$rollback() + }) + + after(async () => { + await prisma.$disconnect() + }) + + it('should be defined', () => { + expect(service).to.be.ok + }) + + describe('getAssignmentsByGroupId', () => { + it('should return ongoing, upcoming assignments when userId is undefined', async () => { + const assignments = await service.getAssignmentsByGroupId(groupId) + expect(assignments.ongoing).to.have.lengthOf(4) + expect(assignments.upcoming).to.have.lengthOf(2) + }) + + it('a assignment should contain following fields when userId is undefined', async () => { + const assignments = await service.getAssignmentsByGroupId(groupId) + expect(assignments.ongoing[0]).to.have.property('title') + expect(assignments.ongoing[0]).to.have.property('startTime') + expect(assignments.ongoing[0]).to.have.property('endTime') + expect(assignments.ongoing[0]).to.have.property('participants') + expect(assignments.ongoing[0].group).to.have.property('id') + expect(assignments.ongoing[0].group).to.have.property('groupName') + expect(assignments.upcoming[0]).to.have.property('title') + expect(assignments.upcoming[0]).to.have.property('startTime') + expect(assignments.upcoming[0]).to.have.property('endTime') + expect(assignments.upcoming[0]).to.have.property('participants') + expect(assignments.upcoming[0].group).to.have.property('id') + expect(assignments.upcoming[0].group).to.have.property('groupName') + }) + + it('should return ongoing, upcoming, registered ongoing, registered upcoming assignments when userId is provided', async () => { + const assignments = await service.getAssignmentsByGroupId( + groupId, + user01Id + ) + expect(assignments.ongoing).to.have.lengthOf(2) + expect(assignments.upcoming).to.have.lengthOf(1) + expect(assignments.registeredOngoing).to.have.lengthOf(2) + expect(assignments.registeredUpcoming).to.have.lengthOf(2) + }) + + it('a assignment should contain following fields when userId is provided', async () => { + const assignments = await service.getAssignmentsByGroupId( + groupId, + user01Id + ) + expect(assignments.ongoing[0]).to.have.property('title') + expect(assignments.ongoing[0]).to.have.property('startTime') + expect(assignments.ongoing[0]).to.have.property('endTime') + expect(assignments.ongoing[0]).to.have.property('participants') + expect(assignments.ongoing[0].group).to.have.property('id') + expect(assignments.ongoing[0].group).to.have.property('groupName') + expect(assignments.upcoming[0]).to.have.property('title') + expect(assignments.upcoming[0]).to.have.property('startTime') + expect(assignments.upcoming[0]).to.have.property('endTime') + expect(assignments.upcoming[0]).to.have.property('participants') + expect(assignments.upcoming[0].group).to.have.property('id') + expect(assignments.upcoming[0].group).to.have.property('groupName') + expect(assignments.registeredOngoing[0]).to.have.property('title') + expect(assignments.registeredOngoing[0]).to.have.property('startTime') + expect(assignments.registeredOngoing[0]).to.have.property('endTime') + expect(assignments.registeredOngoing[0]).to.have.property('participants') + expect(assignments.registeredOngoing[0].group).to.have.property('id') + expect(assignments.registeredOngoing[0].group).to.have.property( + 'groupName' + ) + expect(assignments.registeredUpcoming[0]).to.have.property('title') + expect(assignments.registeredUpcoming[0]).to.have.property('startTime') + expect(assignments.registeredUpcoming[0]).to.have.property('endTime') + expect(assignments.registeredUpcoming[0]).to.have.property('participants') + expect(assignments.registeredUpcoming[0].group).to.have.property('id') + expect(assignments.registeredUpcoming[0].group).to.have.property( + 'groupName' + ) + }) + }) + + describe('getRegisteredOngoingUpcomingAssignments', () => { + it('should return registeredOngoing, registeredUpcoming assignments', async () => { + const assignments = await service.getRegisteredOngoingUpcomingAssignments( + groupId, + user01Id + ) + expect(assignments.registeredOngoing).to.have.lengthOf(2) + expect(assignments.registeredUpcoming).to.have.lengthOf(2) + }) + + it('a assignment should contain following fields', async () => { + const assignments = await service.getRegisteredOngoingUpcomingAssignments( + groupId, + user01Id + ) + expect(assignments.registeredOngoing[0]).to.have.property('title') + expect(assignments.registeredOngoing[0]).to.have.property('startTime') + expect(assignments.registeredOngoing[0]).to.have.property('endTime') + expect(assignments.registeredOngoing[0]).to.have.property('participants') + expect(assignments.registeredOngoing[0].group).to.have.property('id') + expect(assignments.registeredOngoing[0].group).to.have.property( + 'groupName' + ) + expect(assignments.registeredUpcoming[0]).to.have.property('title') + expect(assignments.registeredUpcoming[0]).to.have.property('startTime') + expect(assignments.registeredUpcoming[0]).to.have.property('endTime') + expect(assignments.registeredUpcoming[0]).to.have.property('participants') + expect(assignments.registeredUpcoming[0].group).to.have.property('id') + expect(assignments.registeredUpcoming[0].group).to.have.property( + 'groupName' + ) + }) + + it("shold return assignments whose title contains '신입생'", async () => { + const keyword = '신입생' + const assignments = await service.getRegisteredOngoingUpcomingAssignments( + groupId, + user01Id, + keyword + ) + expect( + assignments.registeredOngoing.map((assignment) => assignment.title) + ).to.deep.equals(['24년도 소프트웨어학과 신입생 입학 과제2']) + }) + }) + + describe('getRegisteredAssignmentIds', async () => { + it("should return an array of assignment's id user01 registered", async () => { + const assignmentIds = await service.getRegisteredAssignmentIds(user01Id) + const registeredAssignmentIds = [1, 3, 5, 7, 9, 11, 13, 15, 17] + assignmentIds.sort((a, b) => a - b) + expect(assignmentIds).to.deep.equal(registeredAssignmentIds) + }) + }) + + describe('getRegisteredFinishedAssignments', async () => { + it('should return only 2 assignments that user01 registered but finished', async () => { + const takeNum = 4 + const assignments = await service.getRegisteredFinishedAssignments( + null, + takeNum, + groupId, + user01Id + ) + expect(assignments.data).to.have.lengthOf(takeNum) + }) + + it('should return a assignment array which starts with id 9', async () => { + const takeNum = 2 + const prevCursor = 11 + const assignments = await service.getRegisteredFinishedAssignments( + prevCursor, + takeNum, + groupId, + user01Id + ) + expect(assignments.data[0].id).to.equals(9) + }) + + it('a assignment should contain following fields', async () => { + const assignments = await service.getRegisteredFinishedAssignments( + null, + 10, + groupId, + user01Id + ) + expect(assignments.data[0]).to.have.property('title') + expect(assignments.data[0]).to.have.property('startTime') + expect(assignments.data[0]).to.have.property('endTime') + expect(assignments.data[0]).to.have.property('participants') + expect(assignments.data[0].group).to.have.property('id') + expect(assignments.data[0].group).to.have.property('groupName') + }) + + it("shold return assignments whose title contains '낮'", async () => { + const keyword = '낮' + const assignments = await service.getRegisteredFinishedAssignments( + null, + 10, + groupId, + user01Id, + keyword + ) + expect( + assignments.data.map((assignment) => assignment.title) + ).to.deep.equals(['소프트의 낮과제']) + }) + }) + + describe('getFinishedAssignmentsByGroupId', () => { + it('should return finished assignments', async () => { + const assignments = await service.getFinishedAssignmentsByGroupId( + null, + null, + 10, + groupId + ) + const assignmentIds = assignments.data + .map((c) => c.id) + .sort((a, b) => a - b) + const finishedAssignmentIds = [6, 7, 8, 9, 10, 11, 12, 13] + expect(assignmentIds).to.deep.equal(finishedAssignmentIds) + }) + }) + + describe('filterOngoing', () => { + it('should return ongoing assignments of the group', () => { + expect(service.filterOngoing(assignments)).to.deep.equal( + ongoingAssignments + ) + }) + }) + + describe('filterUpcoming', () => { + it('should return upcoming assignments of the group', () => { + expect(service.filterUpcoming(assignments)).to.deep.equal( + upcomingAssignments + ) + }) + }) + + describe('getAssignment', () => { + it('should throw error when assignment does not exist', async () => { + await expect( + service.getAssignment(999, groupId, user01Id) + ).to.be.rejectedWith(EntityNotExistException) + }) + + it('should return assignment', async () => { + expect(await service.getAssignment(assignmentId, groupId, user01Id)).to.be + .ok + }) + }) + + describe('createAssignmentRecord', () => { + let assignmentRecordId = -1 + const invitationCode = '123456' + const invalidInvitationCode = '000000' + + it('should throw error when the invitation code does not match', async () => { + await expect( + service.createAssignmentRecord(1, user01Id, invalidInvitationCode) + ).to.be.rejectedWith(ConflictFoundException) + }) + + it('should throw error when the assignment does not exist', async () => { + await expect( + service.createAssignmentRecord(999, user01Id, invitationCode) + ).to.be.rejectedWith(Prisma.PrismaClientKnownRequestError) + }) + + it('should throw error when user is participated in assignment again', async () => { + await expect( + service.createAssignmentRecord(assignmentId, user01Id, invitationCode) + ).to.be.rejectedWith(ConflictFoundException) + }) + + it('should throw error when assignment is not ongoing', async () => { + await expect( + service.createAssignmentRecord(8, user01Id, invitationCode) + ).to.be.rejectedWith(ConflictFoundException) + }) + + it('should register to a assignment successfully', async () => { + const assignmentRecord = await service.createAssignmentRecord( + 2, + user01Id, + invitationCode + ) + assignmentRecordId = assignmentRecord.id + expect( + await transaction.assignmentRecord.findUnique({ + where: { id: assignmentRecordId } + }) + ).to.deep.equals(assignmentRecord) + }) + }) + + describe('deleteAssignmentRecord', () => { + let assignmentRecord: AssignmentRecord | { id: number } = { id: -1 } + + afterEach(async () => { + try { + await transaction.assignmentRecord.delete({ + where: { id: assignmentRecord.id } + }) + } catch (error) { + if ( + !( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) + ) { + throw error + } + } + }) + + it('should return deleted assignment record', async () => { + const newlyRegisteringAssignmentId = 16 + assignmentRecord = await transaction.assignmentRecord.create({ + data: { + assignmentId: newlyRegisteringAssignmentId, + userId: user01Id, + acceptedProblemNum: 0, + score: 0, + totalPenalty: 0 + } + }) + + expect( + await service.deleteAssignmentRecord( + newlyRegisteringAssignmentId, + user01Id + ) + ).to.deep.equal(assignmentRecord) + }) + + it('should throw error when assignment does not exist', async () => { + await expect( + service.deleteAssignmentRecord(999, user01Id) + ).to.be.rejectedWith(EntityNotExistException) + }) + + it('should throw error when assignment record does not exist', async () => { + await expect( + service.deleteAssignmentRecord(16, user01Id) + ).to.be.rejectedWith(EntityNotExistException) + }) + + it('should throw error when assignment is ongoing', async () => { + await expect( + service.deleteAssignmentRecord(assignmentId, user01Id) + ).to.be.rejectedWith(ForbiddenAccessException) + }) + }) +}) diff --git a/apps/backend/apps/client/src/assignment/assignment.service.ts b/apps/backend/apps/client/src/assignment/assignment.service.ts new file mode 100644 index 0000000000..1d3ba116f0 --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.service.ts @@ -0,0 +1,516 @@ +import { Injectable } from '@nestjs/common' +import { Prisma, type Assignment } from '@prisma/client' +import { OPEN_SPACE_ID } from '@libs/constants' +import { + ConflictFoundException, + EntityNotExistException, + ForbiddenAccessException +} from '@libs/exception' +import { PrismaService } from '@libs/prisma' + +const assignmentSelectOption = { + id: true, + title: true, + startTime: true, + endTime: true, + group: { select: { id: true, groupName: true } }, + invitationCode: true, + enableCopyPaste: true, + isJudgeResultVisible: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + select: { + assignmentRecord: true + } + } +} satisfies Prisma.AssignmentSelect + +export type AssignmentSelectResult = Prisma.AssignmentGetPayload<{ + select: typeof assignmentSelectOption +}> + +export type AssignmentResult = Omit & { + participants: number +} + +@Injectable() +export class AssignmentService { + constructor(private readonly prisma: PrismaService) {} + + async getAssignmentsByGroupId( + groupId: number, + userId?: T + ): Promise< + T extends undefined | null + ? { + ongoing: AssignmentResult[] + upcoming: AssignmentResult[] + } + : { + registeredOngoing: AssignmentResult[] + registeredUpcoming: AssignmentResult[] + ongoing: AssignmentResult[] + upcoming: AssignmentResult[] + } + > + async getAssignmentsByGroupId(groupId: number, userId: number | null = null) { + const now = new Date() + if (userId == null) { + const assignments = await this.prisma.assignment.findMany({ + where: { + groupId, + endTime: { + gt: now + }, + isVisible: true + }, + select: assignmentSelectOption, + orderBy: { + endTime: 'asc' + } + }) + + const assignmentsWithParticipants: AssignmentResult[] = + this.renameToParticipants(assignments) + + return { + ongoing: this.filterOngoing(assignmentsWithParticipants), + upcoming: this.filterUpcoming(assignmentsWithParticipants) + } + } + + const registeredAssignmentIds = + await this.getRegisteredAssignmentIds(userId) + + let registeredAssignments: AssignmentSelectResult[] = [] + let restAssignments: AssignmentSelectResult[] = [] + + if (registeredAssignmentIds) { + registeredAssignments = await this.prisma.assignment.findMany({ + where: { + groupId, // TODO: 기획 상 필요한 부분인지 확인하고 삭제 + id: { + in: registeredAssignmentIds + }, + endTime: { + gt: now + } + }, + select: assignmentSelectOption, + orderBy: { + endTime: 'asc' + } + }) + } + + restAssignments = await this.prisma.assignment.findMany({ + where: { + groupId, + isVisible: true, + id: { + notIn: registeredAssignmentIds + }, + endTime: { + gt: now + } + }, + select: assignmentSelectOption, + orderBy: { + endTime: 'asc' + } + }) + + const registeredAssignmentsWithParticipants = this.renameToParticipants( + registeredAssignments + ) + const restAssignmentsWithParticipants = + this.renameToParticipants(restAssignments) + + return { + registeredOngoing: this.filterOngoing( + registeredAssignmentsWithParticipants + ), + registeredUpcoming: this.filterUpcoming( + registeredAssignmentsWithParticipants + ), + ongoing: this.filterOngoing(restAssignmentsWithParticipants), + upcoming: this.filterUpcoming(restAssignmentsWithParticipants) + } + } + + async getRegisteredOngoingUpcomingAssignments( + groupId: number, + userId: number, + search?: string + ) { + const now = new Date() + const registeredAssignmentIds = + await this.getRegisteredAssignmentIds(userId) + + const ongoingAndUpcomings = await this.prisma.assignment.findMany({ + where: { + groupId, + id: { + in: registeredAssignmentIds + }, + endTime: { + gt: now + }, + title: { + contains: search + } + }, + select: assignmentSelectOption + }) + + const ongoingAndUpcomingsWithParticipants = + this.renameToParticipants(ongoingAndUpcomings) + + return { + registeredOngoing: this.filterOngoing( + ongoingAndUpcomingsWithParticipants + ), + registeredUpcoming: this.filterUpcoming( + ongoingAndUpcomingsWithParticipants + ) + } + } + + async getRegisteredAssignmentIds(userId: number) { + const registeredAssignmentRecords = + await this.prisma.assignmentRecord.findMany({ + where: { + userId + }, + select: { + assignmentId: true + } + }) + + return registeredAssignmentRecords.map((obj) => obj.assignmentId) + } + + async getRegisteredFinishedAssignments( + cursor: number | null, + take: number, + groupId: number, + userId: number, + search?: string + ) { + const now = new Date() + const paginator = this.prisma.getPaginator(cursor) + + const registeredAssignmentIds = + await this.getRegisteredAssignmentIds(userId) + const assignments = await this.prisma.assignment.findMany({ + ...paginator, + take, + where: { + groupId, + endTime: { + lte: now + }, + id: { + in: registeredAssignmentIds + }, + title: { + contains: search + }, + isVisible: true + }, + select: assignmentSelectOption, + orderBy: [{ endTime: 'desc' }, { id: 'desc' }] + }) + + const total = await this.prisma.assignment.count({ + where: { + groupId, + endTime: { + lte: now + }, + id: { + in: registeredAssignmentIds + }, + title: { + contains: search + }, + isVisible: true + } + }) + + return { data: this.renameToParticipants(assignments), total } + } + + async getFinishedAssignmentsByGroupId( + userId: number | null, + cursor: number | null, + take: number, + groupId: number, + search?: string + ) { + const paginator = this.prisma.getPaginator(cursor) + const now = new Date() + + const finished = await this.prisma.assignment.findMany({ + ...paginator, + take, + where: { + endTime: { + lte: now + }, + groupId, + isVisible: true, + title: { + contains: search + } + }, + select: assignmentSelectOption, + orderBy: [{ endTime: 'desc' }, { id: 'desc' }] + }) + + const countRenamedAssignments = this.renameToParticipants(finished) + + const finishedAssignmentWithIsRegistered = await Promise.all( + countRenamedAssignments.map(async (assignment) => { + return { + ...assignment, + // userId가 없거나(로그인 안됨) assignment에 참여중이지 않은 경우 false + isRegistered: + !(await this.prisma.assignmentRecord.findFirst({ + where: { + userId, + assignmentId: assignment.id + } + })) || !userId + ? false + : true + } + }) + ) + + const total = await this.prisma.assignment.count({ + where: { + endTime: { + lte: now + }, + groupId, + isVisible: true, + title: { + contains: search + } + } + }) + + return { + data: finishedAssignmentWithIsRegistered, + total + } + } + + // TODO: participants 대신 _count.assignmentRecord 그대로 사용하는 것 고려해보기 + /** 가독성을 위해 _count.assignmentRecord를 participants로 변경한다. */ + renameToParticipants(assignments: AssignmentSelectResult[]) { + return assignments.map(({ _count: countObject, ...rest }) => ({ + ...rest, + participants: countObject.assignmentRecord + })) + } + + filterOngoing(assignments: AssignmentResult[]) { + const now = new Date() + const ongoingAssignment = assignments + .filter( + (assignment) => assignment.startTime <= now && assignment.endTime > now + ) + .sort((a, b) => a.endTime.getTime() - b.endTime.getTime()) + return ongoingAssignment + } + + filterUpcoming(assignments: AssignmentResult[]) { + const now = new Date() + const upcomingAssignment = assignments + .filter((assignment) => assignment.startTime > now) + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()) + return upcomingAssignment + } + + async getAssignment(id: number, groupId = OPEN_SPACE_ID, userId?: number) { + // check if the user has already registered this assignment + // initial value is false + let isRegistered = false + let assignment: Partial + if (userId) { + const hasRegistered = await this.prisma.assignmentRecord.findFirst({ + where: { userId, assignmentId: id } + }) + if (hasRegistered) { + isRegistered = true + } + } + try { + assignment = await this.prisma.assignment.findUniqueOrThrow({ + where: { + id, + groupId, + isVisible: true + }, + select: { + ...assignmentSelectOption, + description: true + } + }) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + throw new EntityNotExistException('Assignment') + } + throw error + } + /* HACK: standings 업데이트 로직 수정 후 삭제 + // get assignment participants ranking using AssignmentRecord + const sortedAssignmentRecordsWithUserDetail = + await this.prisma.assignmentRecord.findMany({ + where: { + assignmentId: id + }, + select: { + user: { + select: { + id: true, + username: true + } + }, + score: true, + totalPenalty: true + }, + orderBy: [ + { + score: 'desc' + }, + { + totalPenalty: 'asc' + } + ] + }) + + const UsersWithStandingDetail = sortedAssignmentRecordsWithUserDetail.map( + (assignmentRecord, index) => ({ + ...assignmentRecord, + standing: index + 1 + }) + ) + */ + // combine assignment and sortedAssignmentRecordsWithUserDetail + + const { invitationCode, ...assignmentDetails } = assignment + const invitationCodeExists = invitationCode != null + return { + ...assignmentDetails, + invitationCodeExists, + isRegistered + } + } + + async createAssignmentRecord( + assignmentId: number, + userId: number, + invitationCode?: string, + groupId = OPEN_SPACE_ID + ) { + const assignment = await this.prisma.assignment.findUniqueOrThrow({ + where: { id: assignmentId, groupId }, + select: { + startTime: true, + endTime: true, + groupId: true, + invitationCode: true + } + }) + + if ( + assignment.invitationCode && + assignment.invitationCode !== invitationCode + ) { + throw new ConflictFoundException('Invalid invitation code') + } + + const hasRegistered = await this.prisma.assignmentRecord.findFirst({ + where: { userId, assignmentId } + }) + if (hasRegistered) { + throw new ConflictFoundException('Already participated this assignment') + } + const now = new Date() + if (now >= assignment.endTime) { + throw new ConflictFoundException('Cannot participate ended assignment') + } + + return await this.prisma.assignmentRecord.create({ + data: { assignmentId, userId } + }) + } + + async isVisible(assignmentId: number, groupId: number): Promise { + return !!(await this.prisma.assignment.count({ + where: { + id: assignmentId, + isVisible: true, + groupId + } + })) + } + + async deleteAssignmentRecord( + assignmentId: number, + userId: number, + groupId = OPEN_SPACE_ID + ) { + let assignment + try { + assignment = await this.prisma.assignment.findUniqueOrThrow({ + where: { id: assignmentId, groupId } + }) + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2025' + ) { + throw new EntityNotExistException('Assignment') + } + } + try { + await this.prisma.assignmentRecord.findFirstOrThrow({ + where: { userId, assignmentId } + }) + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2025' + ) { + throw new EntityNotExistException('AssignmentRecord') + } + } + const now = new Date() + if (now >= assignment.startTime) { + throw new ForbiddenAccessException( + 'Cannot unregister ongoing or ended assignment' + ) + } + + try { + return await this.prisma.assignmentRecord.delete({ + // eslint-disable-next-line @typescript-eslint/naming-convention + where: { assignmentId_userId: { assignmentId, userId } } + }) + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2025' + ) { + throw new EntityNotExistException('AssignmentRecord') + } + } + } +} diff --git a/apps/backend/apps/client/src/submission/mock/submission.mock.ts b/apps/backend/apps/client/src/submission/mock/submission.mock.ts index 0aa6ed8816..acdb9d4561 100644 --- a/apps/backend/apps/client/src/submission/mock/submission.mock.ts +++ b/apps/backend/apps/client/src/submission/mock/submission.mock.ts @@ -20,6 +20,7 @@ export const submissions = [ userIp: '127.0.0.1', problemId: 1, contestId: null, + assignmentId: null, workbookId: null, problem: { problemTestcase: [ @@ -43,6 +44,7 @@ export const submissions = [ userIp: '127.0.0.1', problemId: 1, contestId: null, + assignmentId: null, workbookId: null, problem: { problemTestcase: [ diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index 88d23d69dd..8d7a9def2f 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -376,6 +376,7 @@ export class SubmissionService { score: 0, userId, userIp: null, + assignmentId: null, contestId: null, workbookId: null, codeSize: null, diff --git a/apps/backend/prisma/migrations/20250108071512_rename_contest_to_assignment/migration.sql b/apps/backend/prisma/migrations/20250108071512_rename_contest_to_assignment/migration.sql new file mode 100644 index 0000000000..9a885d10df --- /dev/null +++ b/apps/backend/prisma/migrations/20250108071512_rename_contest_to_assignment/migration.sql @@ -0,0 +1,77 @@ +/* + Warnings: + + - You are about to drop the column `contest_id` on the `announcement` table. All the data in the column will be lost. + - You are about to drop the column `contest_id` on the `submission` table. All the data in the column will be lost. + - You are about to drop the `contest` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `contest_problem` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `contest_record` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `assignment_id` to the `announcement` table without a default value. This is not possible if the table is not empty. + +*/ + +--RENAME TABLE (Contest) +ALTER TABLE contest RENAME TO assignment; + +--RENAME COLUMN +ALTER TABLE contest_record RENAME COLUMN contest_id to assignment_id; + +--RENAME COLUMN +ALTER TABLE contest_problem RENAME COLUMN contest_id to assignment_id; + +--RENAME COLUMN +ALTER TABLE announcement RENAME COLUMN contest_id to assignment_id; + +--RENAME COLUMN +ALTER TABLE submission RENAME COLUMN contest_id to assignment_id; + +-- RenamePrimaryKey +ALTER TABLE "assignment" RENAME CONSTRAINT "contest_pkey" TO "assignment_pkey"; + +-- RenameForeignKey +ALTER TABLE "assignment" RENAME CONSTRAINT "contest_group_id_fkey" TO "assignment_group_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "assignment" RENAME CONSTRAINT "contest_created_by_id_fkey" TO "assignment_create_by_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "assignment" RENAME CONSTRAINT "assignment_create_by_id_fkey" TO "assignment_created_by_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "announcement" RENAME CONSTRAINT "announcement_contest_id_fkey" TO "announcement_assignment_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "submission" RENAME CONSTRAINT "submission_contest_id_fkey" TO "submission_assignment_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "contest_problem" RENAME CONSTRAINT "contest_problem_contest_id_fkey" TO "contest_problem_assignment_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "contest_record" RENAME CONSTRAINT "contest_record_contest_id_fkey" TO "contest_record_assignment_id_fkey"; + +--RENAME TABLE (ContestProblem) +ALTER TABLE contest_problem RENAME TO assignment_problem; + +-- RenamePrimaryKey +ALTER TABLE "assignment_problem" RENAME CONSTRAINT "contest_problem_pkey" TO "assignment_problem_pkey"; + +-- RenameForeignKey +ALTER TABLE "assignment_problem" RENAME CONSTRAINT "contest_problem_assignment_id_fkey" TO "assignment_problem_assignment_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "assignment_problem" RENAME CONSTRAINT "contest_problem_problem_id_fkey" TO "assignment_problem_problem_id_fkey"; + +--RENAME TABLE (AssignmentRecord) +ALTER TABLE contest_record RENAME TO assignment_record; + +-- RenamePrimaryKey +ALTER TABLE "assignment_record" RENAME CONSTRAINT "contest_record_pkey" TO "assignment_record_pkey"; + +-- RenameForeignKey +ALTER TABLE "assignment_record" RENAME CONSTRAINT "contest_record_user_id_fkey" TO "assignment_record_user_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "assignment_record" RENAME CONSTRAINT "contest_record_assignment_id_fkey" TO "assignment_record_assignment_id_fkey"; + +-- RenameIndex +ALTER INDEX "contest_record_contest_id_user_id_key" RENAME TO "assignment_record_assignment_id_user_id_key"; diff --git a/apps/backend/prisma/migrations/20250108111635_add_contest_tables/migration.sql b/apps/backend/prisma/migrations/20250108111635_add_contest_tables/migration.sql new file mode 100644 index 0000000000..c3e5456c39 --- /dev/null +++ b/apps/backend/prisma/migrations/20250108111635_add_contest_tables/migration.sql @@ -0,0 +1,88 @@ +-- AlterTable +ALTER TABLE "announcement" ADD COLUMN "contest_id" INTEGER, +ALTER COLUMN "assignment_id" DROP NOT NULL; + +-- Add Constraint +ALTER TABLE "announcement" +ADD CONSTRAINT "assignmentId_or_contestId_required" +CHECK ( + (contest_id IS NOT NULL AND assignment_id IS NULL) OR + (contest_id IS NULL AND assignment_id IS NOT NULL) +); + +-- AlterTable +ALTER TABLE "submission" ADD COLUMN "contest_id" INTEGER; + +-- CreateTable +CREATE TABLE "contest" ( + "id" SERIAL NOT NULL, + "created_by_id" INTEGER, + "group_id" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "invitation_code" TEXT, + "start_time" TIMESTAMP(3) NOT NULL, + "end_time" TIMESTAMP(3) NOT NULL, + "is_visible" BOOLEAN NOT NULL DEFAULT true, + "is_rank_visible" BOOLEAN NOT NULL DEFAULT true, + "is_judge_result_visible" BOOLEAN NOT NULL DEFAULT true, + "enable_copy_paste" BOOLEAN NOT NULL DEFAULT true, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "contest_problem" ( + "order" INTEGER NOT NULL, + "contest_id" INTEGER NOT NULL, + "problem_id" INTEGER NOT NULL, + "score" INTEGER NOT NULL DEFAULT 0, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contest_problem_pkey" PRIMARY KEY ("contest_id","problem_id") +); + +-- CreateTable +CREATE TABLE "contest_record" ( + "id" SERIAL NOT NULL, + "contest_id" INTEGER NOT NULL, + "user_id" INTEGER, + "accepted_problem_num" INTEGER NOT NULL DEFAULT 0, + "score" INTEGER NOT NULL DEFAULT 0, + "finish_time" TIMESTAMP(3), + "total_penalty" INTEGER NOT NULL DEFAULT 0, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contest_record_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "contest_record_contest_id_user_id_key" ON "contest_record"("contest_id", "user_id"); + +-- AddForeignKey +ALTER TABLE "contest" ADD CONSTRAINT "contest_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest" ADD CONSTRAINT "contest_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest_problem" ADD CONSTRAINT "contest_problem_contest_id_fkey" FOREIGN KEY ("contest_id") REFERENCES "contest"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest_problem" ADD CONSTRAINT "contest_problem_problem_id_fkey" FOREIGN KEY ("problem_id") REFERENCES "problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest_record" ADD CONSTRAINT "contest_record_contest_id_fkey" FOREIGN KEY ("contest_id") REFERENCES "contest"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest_record" ADD CONSTRAINT "contest_record_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "announcement" ADD CONSTRAINT "announcement_contest_id_fkey" FOREIGN KEY ("contest_id") REFERENCES "contest"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "submission" ADD CONSTRAINT "submission_contest_id_fkey" FOREIGN KEY ("contest_id") REFERENCES "contest"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index ed88ceabad..622926f7e2 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -48,17 +48,19 @@ model User { createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") - userProfile UserProfile? - userGroup UserGroup[] - notice Notice[] - problem Problem[] - contest Contest[] - contestRecord ContestRecord[] - workbook Workbook[] - submission Submission[] - useroauth UserOAuth? - CodeDraft CodeDraft[] - Image Image[] + userProfile UserProfile? + userGroup UserGroup[] + notice Notice[] + problem Problem[] + assignment Assignment[] + assignmentRecord AssignmentRecord[] + workbook Workbook[] + submission Submission[] + useroauth UserOAuth? + CodeDraft CodeDraft[] + Image Image[] + contest Contest[] + contestRecord ContestRecord[] @@map("user") } @@ -114,11 +116,12 @@ model Group { createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") - userGroup UserGroup[] - notice Notice[] - problem Problem[] - contest Contest[] - workbook Workbook[] + userGroup UserGroup[] + notice Notice[] + problem Problem[] + assignment Assignment[] + workbook Workbook[] + contest Contest[] @@map("group") } @@ -184,13 +187,14 @@ model Problem { createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") - problemTestcase ProblemTestcase[] - problemTag ProblemTag[] - contestProblem ContestProblem[] - workbookProblem WorkbookProblem[] - submission Submission[] - announcement Announcement[] - codeDraft CodeDraft[] + problemTestcase ProblemTestcase[] + problemTag ProblemTag[] + assignmentProblem AssignmentProblem[] + workbookProblem WorkbookProblem[] + contestProblem ContestProblem[] + submission Submission[] + announcement Announcement[] + codeDraft CodeDraft[] @@map("problem") } @@ -258,6 +262,67 @@ model Tag { @@map("tag") } +model Assignment { + id Int @id @default(autoincrement()) + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + createdById Int? @map("created_by_id") + group Group @relation(fields: [groupId], references: [id]) + groupId Int @map("group_id") + title String + description String + invitationCode String? @map("invitation_code") + startTime DateTime @map("start_time") + endTime DateTime @map("end_time") + isVisible Boolean @default(true) @map("is_visible") + isRankVisible Boolean @default(true) @map("is_rank_visible") + isJudgeResultVisible Boolean @default(true) @map("is_judge_result_visible") // 이 Assignment에 포함된 문제의 Judge Result를 사용자에게 보여줄지 말지 결정합니다. + enableCopyPaste Boolean @default(true) @map("enable_copy_paste") // 이 Assignment에 포함된 문제의 코드 에디터에서 복사-붙여넣기를 허용합니다. + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + assignmentProblem AssignmentProblem[] + assignmentRecord AssignmentRecord[] + submission Submission[] + announcement Announcement[] + + @@map("assignment") +} + +model AssignmentProblem { + order Int + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + assignmentId Int @map("assignment_id") + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int @map("problem_id") + // 각 문제의 점수 (비율 아님) + score Int @default(0) + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@id([assignmentId, problemId]) + // @@unique([assignmentId, problemId]) + @@map("assignment_problem") +} + +model AssignmentRecord { + id Int @id @default(autoincrement()) + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + assignmentId Int @map("assignment_id") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + userId Int? @map("user_id") + acceptedProblemNum Int @default(0) @map("accepted_problem_num") + score Int @default(0) + // finishTime: Pariticipant가 가장 최근에 AC를 받은 시각 + finishTime DateTime? @map("finish_time") + // totalPenalty: Submission 시, AC를 받지 못했을 때, 올라가는 Counter + totalPenalty Int @default(0) @map("total_penalty") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@unique([assignmentId, userId]) + @@map("assignment_record") +} + model Contest { id Int @id @default(autoincrement()) createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) @@ -300,20 +365,6 @@ model ContestProblem { @@map("contest_problem") } -model Announcement { - id Int @id @default(autoincrement()) - content String - contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) - contestId Int @map("contest_id") - problem Problem? @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int? @map("problem_id") - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") - - @@unique([id]) - @@map("announcement") -} - model ContestRecord { id Int @id @default(autoincrement()) contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) @@ -333,6 +384,24 @@ model ContestRecord { @@map("contest_record") } +model Announcement { + id Int @id @default(autoincrement()) + content String + // Assignment와 Contest 둘 중 하나만 설정되어 있어야 합니다 (XOR) + // 아닐시 DB단에서 에러 반환, 20250108111635_add_contest_tables migration 참고 + assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + assignmentId Int? @map("assignment_id") + contest Contest? @relation(fields: [contestId], references: [id], onDelete: Cascade) + contestId Int? @map("contest_id") + problem Problem? @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int? @map("problem_id") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@unique([id]) + @@map("announcement") +} + model Workbook { id Int @id @default(autoincrement()) createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) @@ -366,31 +435,33 @@ model WorkbookProblem { } model Submission { - id Int @id @default(autoincrement()) - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - userId Int? @map("user_id") - userIp String? @map("user_ip") - problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + userId Int? @map("user_id") + userIp String? @map("user_ip") + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) // Todo : // Regardless problem deletion, Keeping submission is positive for ranking or something .... - problemId Int @map("problem_id") - contest Contest? @relation(fields: [contestId], references: [id], onDelete: Cascade) - contestId Int? @map("contest_id") - workbook Workbook? @relation(fields: [workbookId], references: [id]) - workbookId Int? @map("workbook_id") + problemId Int @map("problem_id") + assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + assignmentId Int? @map("assignment_id") + contest Contest? @relation(fields: [contestId], references: [id], onDelete: Cascade) + contestId Int? @map("contest_id") + workbook Workbook? @relation(fields: [workbookId], references: [id]) + workbookId Int? @map("workbook_id") /// code item structure /// { /// "id": number, /// "text": string, /// "locked": boolean /// } - code Json[] - codeSize Int? @map("code_size") - language Language - result ResultStatus - score Int @default(0) /// 100점 만점 기준 점수 - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") + code Json[] + codeSize Int? @map("code_size") + language Language + result ResultStatus + score Int @default(0) /// 100점 만점 기준 점수 + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") submissionResult SubmissionResult[] @@ -423,7 +494,7 @@ enum ResultStatus { OutputLimitExceeded ServerError SegmentationFaultError - Blind // isJudgeResultVisible == False로 설정된 contest의 채점 결과를 반환할 때 사용 + Blind // isJudgeResultVisible == False로 설정된 Assignment/Contest의 채점 결과를 반환할 때 사용 } model CodeDraft { diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 98fe7396f4..700710000e 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -8,12 +8,14 @@ import { type User, type Problem, type Tag, - type Contest, + type Assignment, type Workbook, type Submission, type ProblemTestcase, type Announcement, type CodeDraft, + type AssignmentRecord, + type Contest, type ContestRecord } from '@prisma/client' import { hash } from 'argon2' @@ -35,6 +37,10 @@ let privateGroup: Group const users: User[] = [] const problems: Problem[] = [] let problemTestcases: ProblemTestcase[] = [] +const assignments: Assignment[] = [] +const endedAssignments: Assignment[] = [] +const ongoingAssignments: Assignment[] = [] +const upcomingAssignments: Assignment[] = [] const contests: Contest[] = [] const endedContests: Contest[] = [] const ongoingContests: Contest[] = [] @@ -42,7 +48,8 @@ const upcomingContests: Contest[] = [] const workbooks: Workbook[] = [] const privateWorkbooks: Workbook[] = [] const submissions: Submission[] = [] -const announcements: Announcement[] = [] +const assignmentAnnouncements: Announcement[] = [] +const contestAnnouncements: Announcement[] = [] const createUsers = async () => { // create super admin user @@ -659,7 +666,7 @@ const createProblems = async () => { timeLimit: 2000, memoryLimit: 512, source: '', - visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingContests[0].endTime + visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingAssignments[0].endTime } }) ) @@ -688,7 +695,7 @@ const createProblems = async () => { timeLimit: 2000, memoryLimit: 512, source: 'Canadian Computing Competition(CCC) 2012 Junior 2번', - visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingContests[0].endTime + visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingAssignments[0].endTime } }) ) @@ -717,7 +724,7 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'Canadian Computing Competition(CCC) 2013 Junior 2번', - visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingContests[0].endTime + visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingAssignments[0].endTime } }) ) @@ -746,7 +753,7 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'USACO 2012 US Open Bronze 1번', - visibleLockTime: new Date('2024-01-01T23:59:59.000Z') //endedContests[0].endTime + visibleLockTime: new Date('2024-01-01T23:59:59.000Z') //endedAssignments[0].endTime } }) ) @@ -775,7 +782,7 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'ICPC Regionals NCPC 2009 B번', - visibleLockTime: new Date('2024-01-01T23:59:59.000Z') //endedContests[0].endTime + visibleLockTime: new Date('2024-01-01T23:59:59.000Z') //endedAssignments[0].endTime } }) ) @@ -832,7 +839,7 @@ const createProblems = async () => { hint: '', timeLimit: 2000, memoryLimit: 512, - source: 'COCI 2019/2020 Contest #3 2번', + source: 'COCI 2019/2020 Assignment #3 2번', visibleLockTime: MIN_DATE } }) @@ -1061,7 +1068,7 @@ const createContests = async () => { // Finished Contests { data: { - title: 'Long Time Ago Contest', + title: 'Long Time Ago Assignment', description: '

이 대회는 오래 전에 끝났어요

', createdById: superAdminUser.id, groupId: publicGroup.id, @@ -1188,7 +1195,7 @@ const createContests = async () => { // Upcoming Contests { data: { - title: 'Future Contest', + title: 'Future Assignment', description: '

이 대회는 언젠가 열리겠죠...?

', createdById: superAdminUser.id, groupId: publicGroup.id, @@ -1218,7 +1225,7 @@ const createContests = async () => { data: { title: '2024 스꾸딩 프로그래밍 대회', description: - '

이 대회는 언젠가 열리겠죠...? isVisible이 false인 contest입니다

', + '

이 대회는 언젠가 열리겠죠...? isVisible이 false인 assignment입니다

', createdById: superAdminUser.id, groupId: publicGroup.id, startTime: new Date('3024-01-01T00:00:00.000Z'), @@ -1284,6 +1291,347 @@ const createContests = async () => { // TODO: add records and ranks } +const createAssignments = async () => { + const assignmentData: { + data: { + title: string + description: string + createdById: number + groupId: number + startTime: Date + endTime: Date + isVisible: boolean + isRankVisible: boolean + invitationCode: string | null + enableCopyPaste: boolean + } + }[] = [ + // Ongoing Assignments + { + data: { + title: 'SKKU Coding Platform 모의과제', + description: `

+ 대통령은 내란 또는 외환의 죄를 범한 경우를 제외하고는 재직중 형사상의 소추를 + 받지 아니한다. 모든 국민은 자기의 행위가 아닌 친족의 행위로 인하여 불이익한 + 처우를 받지 아니한다. +

+ +

+ 위원은 탄핵 또는 금고 이상의 형의 선고에 의하지 아니하고는 파면되지 아니한다. + 대통령은 국무회의의 의장이 되고, 국무총리는 부의장이 된다. 모든 국민은 헌법과 + 법률이 정한 법관에 의하여 법률에 의한 재판을 받을 권리를 가진다. +

+ +

+ 국회의원은 현행범인인 경우를 제외하고는 회기중 국회의 동의없이 체포 또는 + 구금되지 아니한다. 헌법재판소의 장은 국회의 동의를 얻어 재판관중에서 대통령이 + 임명한다. +

+ +

+ 국가는 지역간의 균형있는 발전을 위하여 지역경제를 육성할 의무를 진다. 제3항의 + 승인을 얻지 못한 때에는 그 처분 또는 명령은 그때부터 효력을 상실한다. 이 경우 + 그 명령에 의하여 개정 또는 폐지되었던 법률은 그 명령이 승인을 얻지 못한 때부터 + 당연히 효력을 회복한다. +

+ +

+ 모든 국민은 신체의 자유를 가진다. 누구든지 법률에 의하지 아니하고는 + 체포·구속·압수·수색 또는 심문을 받지 아니하며, 법률과 적법한 절차에 의하지 + 아니하고는 처벌·보안처분 또는 강제노역을 받지 아니한다. +

`, + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '24년도 소프트웨어학과 신입생 입학 과제1', + description: '

이 과제는 현재 진행 중입니다 !

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + }, + { + data: { + title: '24년도 소프트웨어학과 신입생 입학 과제2', + description: '

이 과제는 현재 진행 중입니다 !

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '24년도 소프트웨어학과 신입생 입학 과제3', + description: '

이 과제는 현재 진행 중입니다 !

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + }, + { + data: { + title: '24년도 아늑배 스파게티 코드 만들기 과제', + description: '

이 과제는 현재 진행 중입니다 ! (private group)

', + createdById: superAdminUser.id, + groupId: privateGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + }, + // Finished Assignments + { + data: { + title: 'Long Time Ago Assignment', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '23년도 소프트웨어학과 신입생 입학 과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 아침과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 낮과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 밤과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '2023 SKKU 프로그래밍 과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 오전과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 오후과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: null, + enableCopyPaste: true + } + }, + { + data: { + title: '23년도 아늑배 스파게티 코드 만들기 과제', + description: '

이 과제는 오래 전에 끝났어요 (private group)

', + createdById: superAdminUser.id, + groupId: privateGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + }, + // Upcoming Assignments + { + data: { + title: 'Future Assignment', + description: '

이 과제는 언젠가 열리겠죠...?

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('3024-01-01T00:00:00.000Z'), + endTime: new Date('3025-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '2024 SKKU 프로그래밍 과제', + description: '

이 과제는 언젠가 열리겠죠...?

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('3024-01-01T00:00:00.000Z'), + endTime: new Date('3025-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '2024 스꾸딩 프로그래밍 과제', + description: + '

이 과제는 언젠가 열리겠죠...? isVisible이 false인 assignment입니다

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('3024-01-01T00:00:00.000Z'), + endTime: new Date('3025-01-01T23:59:59.000Z'), + isVisible: false, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '25년도 아늑배 스파게티 코드 만들기 과제', + description: '

이 과제는 언젠가 열리겠죠...? (private group)

', + createdById: superAdminUser.id, + groupId: privateGroup.id, + startTime: new Date('3024-01-01T00:00:00.000Z'), + endTime: new Date('3025-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + } + ] + + const now = new Date() + for (const obj of assignmentData) { + const assignment = await prisma.assignment.create(obj) + assignments.push(assignment) + if (now < obj.data.startTime) { + upcomingAssignments.push(assignment) + } else if (obj.data.endTime < now) { + endedAssignments.push(assignment) + } else { + ongoingAssignments.push(assignment) + } + } + + // add problems to ongoing assignment + for (const problem of problems.slice(0, 3)) { + await prisma.assignmentProblem.create({ + data: { + order: problem.id - 1, + assignmentId: ongoingAssignments[0].id, + problemId: problem.id, + score: problem.id * 10 + } + }) + } + + // add problems to finished assignment + for (const problem of problems.slice(3, 5)) { + await prisma.assignmentProblem.create({ + data: { + order: problem.id - 1, + assignmentId: endedAssignments[0].id, + problemId: problem.id + } + }) + } + + // TODO: add records and ranks +} + const createWorkbooks = async () => { for (let i = 1; i <= 3; i++) { workbooks.push( @@ -1334,7 +1682,7 @@ const createSubmissions = async () => { data: { userId: users[0].id, problemId: problems[0].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1373,7 +1721,7 @@ int main(void) { data: { userId: users[1].id, problemId: problems[1].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1411,7 +1759,7 @@ int main(void) { data: { userId: users[2].id, problemId: problems[2].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1447,7 +1795,7 @@ int main(void) { data: { userId: users[3].id, problemId: problems[3].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1481,7 +1829,7 @@ int main(void) { data: { userId: users[4].id, problemId: problems[4].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1588,11 +1936,40 @@ int main(void) { } const createAnnouncements = async () => { + // For Assignments + for (let i = 0; i < 5; ++i) { + assignmentAnnouncements.push( + await prisma.announcement.create({ + data: { + content: `Announcement(assignment)_0_${i}`, + assignmentId: ongoingAssignments[i].id + } + }) + ) + } + + for (let i = 0; i < 5; ++i) { + assignmentAnnouncements.push( + await prisma.announcement.create({ + data: { + content: `Announcement(assignment)_1_${i}... +아래 내용은 한글 Lorem Ipsum으로 생성된 내용입니다! 별 의미 없어요. +모든 국민은 신속한 재판을 받을 권리를 가진다. 형사피고인은 상당한 이유가 없는 한 지체없이 공개재판을 받을 권리를 가진다. +법관은 탄핵 또는 금고 이상의 형의 선고에 의하지 아니하고는 파면되지 아니하며, 징계처분에 의하지 아니하고는 정직·감봉 기타 불리한 처분을 받지 아니한다. +일반사면을 명하려면 국회의 동의를 얻어야 한다. 연소자의 근로는 특별한 보호를 받는다.`, + assignmentId: ongoingAssignments[i].id, + problemId: problems[i].id + } + }) + ) + } + + // For Contests for (let i = 0; i < 5; ++i) { - announcements.push( + contestAnnouncements.push( await prisma.announcement.create({ data: { - content: `Announcement_0_${i}`, + content: `Announcement(contest)_0_${i}`, contestId: ongoingContests[i].id } }) @@ -1600,10 +1977,10 @@ const createAnnouncements = async () => { } for (let i = 0; i < 5; ++i) { - announcements.push( + contestAnnouncements.push( await prisma.announcement.create({ data: { - content: `Announcement_1_${i}... + content: `Announcement(contest)_1_${i}... 아래 내용은 한글 Lorem Ipsum으로 생성된 내용입니다! 별 의미 없어요. 모든 국민은 신속한 재판을 받을 권리를 가진다. 형사피고인은 상당한 이유가 없는 한 지체없이 공개재판을 받을 권리를 가진다. 법관은 탄핵 또는 금고 이상의 형의 선고에 의하지 아니하고는 파면되지 아니하며, 징계처분에 의하지 아니하고는 정직·감봉 기타 불리한 처분을 받지 아니한다. @@ -1672,6 +2049,50 @@ const createCodeDrafts = async () => { return codeDrafts } +const createAssignmentRecords = async () => { + const assignmentRecords: AssignmentRecord[] = [] + // group 1 users + const group1Users = await prisma.userGroup.findMany({ + where: { + groupId: 1 + } + }) + for (const user of group1Users) { + const assignmentRecord = await prisma.assignmentRecord.create({ + data: { + userId: user.userId, + assignmentId: 1, + acceptedProblemNum: 0, + totalPenalty: 0 + } + }) + assignmentRecords.push(assignmentRecord) + } + + // upcoming assignment에 참가한 User 1의 assignment register를 un-register하는 기능과, + // registered upcoming, ongoing, finished assignment를 조회하는 기능을 확인하기 위함 + const user01Id = 4 + for ( + let assignmentId = 3; + assignmentId <= assignments.length; + assignmentId += 2 + ) { + assignmentRecords.push( + await prisma.assignmentRecord.create({ + data: { + userId: user01Id, + assignmentId, + acceptedProblemNum: 0, + score: 0, + totalPenalty: 0 + } + }) + ) + } + + return assignmentRecords +} + const createContestRecords = async () => { const contestRecords: ContestRecord[] = [] // group 1 users @@ -1717,11 +2138,13 @@ const main = async () => { await createGroups() await createNotices() await createProblems() + await createAssignments() await createContests() await createWorkbooks() await createSubmissions() await createAnnouncements() await createCodeDrafts() + await createAssignmentRecords() await createContestRecords() } diff --git a/apps/frontend/.eslintrc.js b/apps/frontend/.eslintrc.js deleted file mode 100644 index e8593ab767..0000000000 --- a/apps/frontend/.eslintrc.js +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -module.exports = { - extends: ['next', 'next/core-web-vitals'], - env: { - browser: true, - node: true - }, - rules: { - '@next/next/no-html-link-for-pages': [ - 'error', - require('path').join(__dirname, 'app') - ] - }, - overrides: [ - { - files: ['*.tsx'], - excludedFiles: ['components/shadcn/*.tsx'], - rules: { - 'react/function-component-definition': [ - 'error', - { - namedComponents: 'function-declaration' - } - ], - 'func-style': ['off'], - 'no-restricted-imports': [ - 'error', - { - name: '@apollo/client', - importNames: ['gql'], - message: 'Please use @generated instead.' - }, - { - name: '@/__generated__', - message: 'Please use @generated instead.' - }, - { - name: '@/__generated__/graphql', - message: 'Please use @generated/graphql instead.' - } - ] - } - } - ] -} diff --git a/apps/frontend/app/(client)/(code-editor)/_components/EditorLayout.tsx b/apps/frontend/app/(client)/(code-editor)/_components/EditorLayout.tsx index 3cebdb21e0..f881289efb 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/EditorLayout.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/EditorLayout.tsx @@ -40,7 +40,7 @@ export default async function EditorLayout({ problem = { ...contestProblem.problem, order: contestProblem.order } contest = await fetcher(`contest/${contestId}`).json() - contest ? (contest.status = 'ongoing') : null // TODO: refactor this after change status interactively + contest && (contest.status = 'ongoing') // TODO: refactor this after change status interactively } else { problem = await fetcher(`problem/${problemId}`).json() } diff --git a/apps/frontend/app/(client)/(main)/settings/_components/CurrentPwSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/CurrentPwSection.tsx index 346824b56f..1eae0835b0 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/CurrentPwSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/CurrentPwSection.tsx @@ -3,35 +3,30 @@ import { Input } from '@/components/shadcn/input' import { cn } from '@/libs/utils' import invisibleIcon from '@/public/icons/invisible.svg' import visibleIcon from '@/public/icons/visible.svg' -import type { SettingsFormat } from '@/types/type' import Image from 'next/image' import React from 'react' -import type { FieldErrors, UseFormRegister } from 'react-hook-form' import { FaCheck } from 'react-icons/fa6' +import { useSettingsContext } from './context' interface CurrentPwSectionProps { currentPassword: string isCheckButtonClicked: boolean isPasswordCorrect: boolean - setPasswordShow: React.Dispatch> - passwordShow: boolean checkPassword: () => Promise - register: UseFormRegister - errors: FieldErrors - updateNow: boolean } export default function CurrentPwSection({ currentPassword, isCheckButtonClicked, isPasswordCorrect, - setPasswordShow, - passwordShow, - checkPassword, - register, - errors, - updateNow + checkPassword }: CurrentPwSectionProps) { + const { + passwordState: { passwordShow, setPasswordShow }, + updateNow, + formState: { register, errors } + } = useSettingsContext() + return ( <> diff --git a/apps/frontend/app/(client)/(main)/settings/_components/IdSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/IdSection.tsx index 4f22d13b8b..57996dfe7f 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/IdSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/IdSection.tsx @@ -1,17 +1,14 @@ import { Input } from '@/components/shadcn/input' +import { useSettingsContext } from './context' + +export default function IdSection() { + const { isLoading, defaultProfileValues } = useSettingsContext() -export default function IdSection({ - isLoading, - defaultUsername -}: { - isLoading: boolean - defaultUsername: string -}) { return ( <> diff --git a/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx index d8f73776f8..a18dafcfe9 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/MajorSection.tsx @@ -17,28 +17,16 @@ import { majors } from '@/libs/constants' import { cn } from '@/libs/utils' import React from 'react' import { FaChevronDown, FaCheck } from 'react-icons/fa6' +import { useSettingsContext } from './context' -interface MajorSectionProps { - majorOpen: boolean - setMajorOpen: React.Dispatch> - majorValue: string - setMajorValue: React.Dispatch> - updateNow: boolean - isLoading: boolean - defaultProfileValues: { - major?: string - } -} +export default function MajorSection() { + const { + isLoading, + updateNow, + majorState: { majorOpen, setMajorOpen, majorValue, setMajorValue }, + defaultProfileValues + } = useSettingsContext() -export default function MajorSection({ - majorOpen, - setMajorOpen, - majorValue, - setMajorValue, - updateNow, - isLoading, - defaultProfileValues -}: MajorSectionProps) { return ( <> diff --git a/apps/frontend/app/(client)/(main)/settings/_components/NameSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/NameSection.tsx index 0946919f16..d8a4714144 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/NameSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/NameSection.tsx @@ -1,25 +1,19 @@ import { Input } from '@/components/shadcn/input' import { cn } from '@/libs/utils' -import type { SettingsFormat } from '@/types/type' -import type { FieldErrors, UseFormRegister } from 'react-hook-form' +import { useSettingsContext } from './context' interface NameSectionProps { - isLoading: boolean - updateNow: boolean - defaultProfileValues: { userProfile?: { realName?: string } } - register: UseFormRegister - errors: FieldErrors realName: string } -export default function NameSection({ - isLoading, - updateNow, - defaultProfileValues, - register, - errors, - realName -}: NameSectionProps) { +export default function NameSection({ realName }: NameSectionProps) { + const { + isLoading, + updateNow, + defaultProfileValues, + formState: { register, errors } + } = useSettingsContext() + return ( <> diff --git a/apps/frontend/app/(client)/(main)/settings/_components/NewPwSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/NewPwSection.tsx index 8e7730c440..09d5757f3b 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/NewPwSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/NewPwSection.tsx @@ -2,34 +2,29 @@ import { Input } from '@/components/shadcn/input' import { cn } from '@/libs/utils' import invisibleIcon from '@/public/icons/invisible.svg' import visibleIcon from '@/public/icons/visible.svg' -import type { SettingsFormat } from '@/types/type' import Image from 'next/image' import React from 'react' -import type { FieldErrors, UseFormRegister } from 'react-hook-form' +import { useSettingsContext } from './context' interface NewPwSectionProps { - newPasswordShow: boolean - setNewPasswordShow: React.Dispatch> newPasswordAble: boolean isPasswordsMatch: boolean newPassword: string confirmPassword: string - updateNow: boolean - register: UseFormRegister - errors: FieldErrors } export default function NewPwSection({ - newPasswordShow, - setNewPasswordShow, newPasswordAble, isPasswordsMatch, newPassword, - confirmPassword, - updateNow, - register, - errors + confirmPassword }: NewPwSectionProps) { + const { + passwordState: { newPasswordShow, setNewPasswordShow }, + updateNow, + formState: { register, errors } + } = useSettingsContext() + return ( <>
diff --git a/apps/frontend/app/(client)/(main)/settings/_components/ReEnterNewPwSection.tsx b/apps/frontend/app/(client)/(main)/settings/_components/ReEnterNewPwSection.tsx index 0aa62d2649..75f40a0ced 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/ReEnterNewPwSection.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/ReEnterNewPwSection.tsx @@ -4,29 +4,28 @@ import visibleIcon from '@/public/icons/visible.svg' import type { SettingsFormat } from '@/types/type' import Image from 'next/image' import React from 'react' -import type { UseFormRegister, UseFormGetValues } from 'react-hook-form' +import type { UseFormGetValues } from 'react-hook-form' +import { useSettingsContext } from './context' interface ReEnterNewPwSectionProps { - confirmPasswordShow: boolean - setConfirmPasswordShow: React.Dispatch> newPasswordAble: boolean - updateNow: boolean - register: UseFormRegister getValues: UseFormGetValues confirmPassword: string isPasswordsMatch: boolean } export default function ReEnterNewPwSection({ - confirmPasswordShow, - setConfirmPasswordShow, newPasswordAble, - updateNow, - register, getValues, confirmPassword, isPasswordsMatch }: ReEnterNewPwSectionProps) { + const { + updateNow, + passwordState: { confirmPasswordShow, setConfirmPasswordShow }, + formState: { register } + } = useSettingsContext() + return ( <> {/* Re-enter new password */} diff --git a/apps/frontend/app/(client)/(main)/settings/_components/SaveButton.tsx b/apps/frontend/app/(client)/(main)/settings/_components/SaveButton.tsx index a7a458a290..b31c7740f8 100644 --- a/apps/frontend/app/(client)/(main)/settings/_components/SaveButton.tsx +++ b/apps/frontend/app/(client)/(main)/settings/_components/SaveButton.tsx @@ -1,18 +1,19 @@ import { Button } from '@/components/shadcn/button' +import { useSettingsContext } from './context' interface SaveButtonProps { - updateNow: boolean saveAbleUpdateNow: boolean saveAble: boolean onSubmitClick: () => void } export default function SaveButton({ - updateNow, saveAbleUpdateNow, saveAble, onSubmitClick }: SaveButtonProps) { + const { updateNow } = useSettingsContext() + return (