diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 558ff855a7..52b8c4cb45 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,7 @@ "database", "cache", "rabbitmq", - "testcase", + "storage", "test-database" ], "workspaceFolder": "/workspace", diff --git a/.env.stage b/.env.stage index bc73955011..b60c7aba04 100644 --- a/.env.stage +++ b/.env.stage @@ -22,17 +22,26 @@ RABBITMQ_CONSUMER_CONNECTION_NAME=iris-consumer RABBITMQ_CONSUMER_TAG=consumer RABBITMQ_PRODUCER_CONNECTION_NAME=iris-producer +# Storage +STORAGE_BUCKET_ENDPOINT_URL=http://127.0.0.1:9000 # Testcase Endpoint TESTCASE_BUCKET_NAME=test-bucket -TESTCASE_ENDPOINT_URL=http://127.0.0.1:9000 +# TESTCASE_ENDPOINT_URL=http://127.0.0.1:9000 TESTCASE_ACCESS_KEY=skku TESTCASE_SECRET_KEY=skku1234 +# Media Upload Endpoint +MEDIA_BUCKET_NAME=image-bucket +# MEDIA_BUCKET_BASE_URL=http://127.0.0.1:9000/image-bucket/ +MEDIA_ACCESS_KEY=skku +MEDIA_SECRET_KEY=skku1234 + REDIS_HOST=127.0.0.1 REDIS_PORT=6380 DATABASE_URL=postgresql://postgres:1234@127.0.0.1:5433/skkuding?schema=public TEST_DATABASE_URL=postgresql://postgres:1234@127.0.0.1:5434/skkuding?schema=public + # TODO: Add information where each of these variables are used # TODO: I want to edit values after the container is created... diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index 06084ce560..76d9777cf8 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -31,7 +31,7 @@ jobs: uses: aws-actions/amazon-ecr-login@v2 - name: Build and push image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: file: ./apps/backend/Dockerfile push: true @@ -55,7 +55,7 @@ jobs: uses: aws-actions/amazon-ecr-login@v2 - name: Build and push image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: file: ./apps/backend/Dockerfile push: true @@ -79,7 +79,7 @@ jobs: uses: aws-actions/amazon-ecr-login@v2 - name: Build and push image (iris) - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: push: true context: '{{defaultContext}}:apps/iris' @@ -122,7 +122,7 @@ jobs: - name: Terraform Init working-directory: ./apps/infra/deploy - run: terraform init -upgrade + run: terraform init # TODO: plan in the other job, and reuse the plan file - name: Terraform Plan diff --git a/.github/workflows/cd-stage.yml b/.github/workflows/cd-stage.yml index 888c4ec551..03d12ecd60 100644 --- a/.github/workflows/cd-stage.yml +++ b/.github/workflows/cd-stage.yml @@ -25,7 +25,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: file: ./apps/backend/Dockerfile # build with root context push: true @@ -47,7 +47,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: file: ./apps/backend/Dockerfile # build with root context push: true @@ -69,7 +69,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push iris image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: push: true context: '{{defaultContext}}:apps/iris' @@ -143,8 +143,8 @@ jobs: - name: Run Bruno Collection (Client) working-directory: ./collection/client - run: bru run --env stage + run: bru run --env Stage - name: Run Bruno Collection (Admin) working-directory: ./collection/admin - run: bru run --env stage + run: bru run --env Stage diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc4e84784b..e257ce9515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,25 +7,67 @@ on: types: [opened, edited, synchronize, reopened] jobs: - build: - name: Build + build-backend: + name: Build Backend runs-on: ubuntu-latest strategy: matrix: - target: [frontend, backend, backend-admin] + target: [client, admin] steps: - uses: actions/checkout@v4 + + - name: Check if source code has changed + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + shared: &shared + - 'apps/backend/libs/**' + - 'apps/backend/prisma/**' + - 'apps/backend/*.json' + - 'pnpm-lock.yaml' + client: + - *shared + - 'apps/backend/apps/client/**' + admin: + - *shared + - 'apps/backend/apps/admin/**' + - uses: ./.github/actions/setup-pnpm + if: ${{ steps.filter.outputs[matrix.target] == 'true' }} - name: Generate Prisma Client - if: ${{ matrix.target == 'backend' || matrix.target == 'backend-admin' }} + if: ${{ steps.filter.outputs[matrix.target] == 'true' }} run: pnpm --filter="@codedang/backend" exec prisma generate + - name: Build + if: ${{ steps.filter.outputs[matrix.target] == 'true' }} + run: pnpm --filter="@codedang/backend" build ${{ matrix.target }} + + build-frontend: + name: Build Frontend + 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: | + frontend: + - 'apps/frontend/**' + - 'pnpm-lock.yaml' + + - uses: ./.github/actions/setup-pnpm + if: steps.filter.outputs.frontend == 'true' + - name: Setup Next.js build cache - if: ${{ matrix.target == 'frontend' }} uses: actions/cache@v4 + if: steps.filter.outputs.frontend == 'true' with: path: apps/frontend/.next/cache key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} @@ -33,18 +75,15 @@ jobs: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}- - name: Load Next.js environment - if: ${{ matrix.target == 'frontend' }} + if: steps.filter.outputs.frontend == 'true' run: | echo "NEXT_PUBLIC_BASEURL=https://stage.codedang.com/api" >> apps/frontend/.env echo "NEXT_PUBLIC_GQL_BASEURL=https://stage.codedang.com/graphql" >> apps/frontend/.env echo "NEXT_URL=https://stage.codedang.com" >> apps/frontend/.env - - name: Build (backend admin) - if: ${{ matrix.target == 'backend-admin' }} - run: pnpm --filter="@codedang/backend" build admin - name: Build - if: ${{ matrix.target != 'backend-admin' }} - run: pnpm --filter="apps/${{ matrix.target }}" build + if: steps.filter.outputs.frontend == 'true' + run: pnpm --filter="@codedang/frontend" build typecheck: name: Typecheck @@ -52,12 +91,24 @@ jobs: steps: - uses: actions/checkout@v4 + + - name: Check if source code has changed + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + backend: + - 'apps/backend/**' + - uses: ./.github/actions/setup-pnpm + if: steps.filter.outputs.backend == 'true' - name: Generate Prisma Client + if: steps.filter.outputs.backend == 'true' run: pnpm --filter="@codedang/backend" exec prisma generate - name: Check types (backend) # For spec files + if: steps.filter.outputs.backend == 'true' run: pnpm --filter="@codedang/backend" exec tsc --noEmit # Typecheck is not performed for frontend intentionally. @@ -98,8 +149,8 @@ jobs: - name: Lint (Node.js) run: pnpm lint - test: - name: Test + test-backend: + name: Test Backend runs-on: ubuntu-latest env: @@ -118,9 +169,21 @@ jobs: steps: - uses: actions/checkout@v4 + + - name: Check if source code has changed + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + backend: + - 'apps/backend/**' + - 'pnpm-lock.yaml' + - uses: ./.github/actions/setup-pnpm + if: steps.filter.outputs.backend == 'true' - name: Check Prisma Migration + if: steps.filter.outputs.backend == 'true' run: | pnpm --filter="@codedang/backend" exec prisma migrate diff \ --from-migrations ./prisma/migrations \ @@ -131,7 +194,9 @@ jobs: "Please run 'pnpm prisma migrate dev' locally and commit the changes." && exit 1) - name: Migrate Prisma + if: steps.filter.outputs.backend == 'true' run: pnpm --filter="@codedang/backend" exec prisma migrate reset --force - name: Test - run: pnpm -r test + if: steps.filter.outputs.backend == 'true' + run: pnpm --filter="@codedang/backend" test diff --git a/.gitpod.yml b/.gitpod.yml index 51c1ca3a13..89a3c3d58f 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -23,7 +23,7 @@ ports: - port: 15672 # RabbitMQ Dashboard onOpen: ignore - - port: 30000 # Testcase Server + - port: 30000 # Storage Server onOpen: ignore tasks: diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 8f0eaa1521..7f8a02846c 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -4,7 +4,7 @@ ### BUILDER ### ARG target=client -FROM node:20.13.0-alpine AS builder +FROM node:20.15.0-alpine AS builder ARG target COPY . /build @@ -18,7 +18,7 @@ RUN npx prisma generate RUN npm run build ${target} ### PRODUCTION ### -FROM node:20.13.0-alpine +FROM node:20.15.0-alpine ARG target ENV NODE_ENV=production diff --git a/apps/backend/apps/admin/src/problem/model/image.output.ts b/apps/backend/apps/admin/src/problem/model/image.output.ts new file mode 100644 index 0000000000..b70a88931e --- /dev/null +++ b/apps/backend/apps/admin/src/problem/model/image.output.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql' + +@ObjectType({ description: 'image' }) +export class ImageSource { + @Field(() => String) + src: string +} diff --git a/apps/backend/apps/admin/src/problem/problem.module.ts b/apps/backend/apps/admin/src/problem/problem.module.ts index a701925fc8..2d91f3ed09 100644 --- a/apps/backend/apps/admin/src/problem/problem.module.ts +++ b/apps/backend/apps/admin/src/problem/problem.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { StorageModule } from '@admin/storage/storage.module' import { ProblemTagResolver, TagResolver } from './problem-tag.resolver' import { @@ -9,7 +10,7 @@ import { import { ProblemService } from './problem.service' @Module({ - imports: [StorageModule], + imports: [StorageModule, ConfigModule], providers: [ ProblemResolver, ProblemTagResolver, diff --git a/apps/backend/apps/admin/src/problem/problem.resolver.ts b/apps/backend/apps/admin/src/problem/problem.resolver.ts index 8c713f448c..9a83cfe470 100644 --- a/apps/backend/apps/admin/src/problem/problem.resolver.ts +++ b/apps/backend/apps/admin/src/problem/problem.resolver.ts @@ -19,12 +19,14 @@ import { } from '@nestjs/graphql' import { ContestProblem, + Image, Problem, ProblemTag, ProblemTestcase, WorkbookProblem } from '@generated' import { Prisma } from '@prisma/client' +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' import { AuthenticatedRequest } from '@libs/auth' import { OPEN_SPACE_ID } from '@libs/constants' import { @@ -33,6 +35,7 @@ import { UnprocessableDataException } from '@libs/exception' import { CursorValidationPipe, GroupIDPipe, RequiredIntPipe } from '@libs/pipe' +import { ImageSource } from './model/image.output' import { CreateProblemInput, UploadFileInput, @@ -105,6 +108,43 @@ export class ProblemResolver { } } + @Mutation(() => ImageSource) + async uploadImage( + @Args('input') input: UploadFileInput, + @Context('req') req: AuthenticatedRequest + ) { + try { + return await this.problemService.uploadImage(input, req.user.id) + } catch (error) { + if (error instanceof UnprocessableDataException) { + throw error.convert2HTTPException() + } + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Mutation(() => Image) + async deleteImage( + @Args('filename') filename: string, + @Context('req') req: AuthenticatedRequest + ) { + try { + return await this.problemService.deleteImage(filename, req.user.id) + } catch (error) { + if (error instanceof UnprocessableDataException) { + throw error.convert2HTTPException() + } else if ( + error instanceof PrismaClientKnownRequestError && + error.code == 'P2025' + ) { + throw new NotFoundException(error.message) + } + this.logger.error(error) + throw new InternalServerErrorException() + } + } + @Query(() => [Problem]) async getProblems( @Args( diff --git a/apps/backend/apps/admin/src/problem/problem.service.spec.ts b/apps/backend/apps/admin/src/problem/problem.service.spec.ts index 1077a85879..a249a38378 100644 --- a/apps/backend/apps/admin/src/problem/problem.service.spec.ts +++ b/apps/backend/apps/admin/src/problem/problem.service.spec.ts @@ -11,7 +11,7 @@ import { UnprocessableDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' -import { S3Provider } from '@admin/storage/s3.provider' +import { S3MediaProvider, S3Provider } from '@admin/storage/s3.provider' import { StorageService } from '@admin/storage/storage.service' import { exampleContest, @@ -81,6 +81,9 @@ const db = { update: stub() }, $transaction: stub(), + image: { + deleteMany: stub() + }, getPaginator: PrismaService.prototype.getPaginator } @@ -96,6 +99,7 @@ describe('ProblemService', () => { StorageService, ConfigService, S3Provider, + S3MediaProvider, { provide: CACHE_MANAGER, useValue: { del: () => null } } ] }).compile() diff --git a/apps/backend/apps/admin/src/problem/problem.service.ts b/apps/backend/apps/admin/src/problem/problem.service.ts index bdfb67ba90..688b35b208 100644 --- a/apps/backend/apps/admin/src/problem/problem.service.ts +++ b/apps/backend/apps/admin/src/problem/problem.service.ts @@ -5,12 +5,17 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' import { Language } from '@generated' import type { ContestProblem, Tag, WorkbookProblem } from '@generated' import { Level } from '@generated' import type { ProblemWhereInput } from '@generated' import { Prisma } from '@prisma/client' +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' +import { randomUUID } from 'crypto' import { Workbook } from 'exceljs' +import type { ReadStream } from 'fs' +import { MAX_IMAGE_SIZE } from '@libs/constants' import { DuplicateFoundException, EntityNotExistException, @@ -41,6 +46,7 @@ export class ProblemService { constructor( private readonly prisma: PrismaService, private readonly storageService: StorageService, + private readonly config: ConfigService, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache ) {} @@ -133,25 +139,20 @@ export class ProblemService { throw new UnprocessableDataException( 'Extensions except Excel(.xlsx, .xls) are not supported.' ) - const header = {} const problems: CreateProblemInput[] = [] - const workbook = new Workbook() const worksheet = (await workbook.xlsx.read(createReadStream())) .worksheets[0] - worksheet.getRow(1).eachCell((cell, idx) => { if (!ImportedProblemHeader.includes(cell.text)) throw new UnprocessableFileDataException( - `Field ${cell.text} is not supported`, - filename, - 1 + `Field ${cell.text} is not supported: ${1}`, + filename ) header[cell.text] = idx }) worksheet.spliceRows(1, 1) - const unsupportedFields = [ header['InputFileName'], header['InputFilePath'], @@ -162,9 +163,8 @@ export class ProblemService { for (const colNumber of unsupportedFields) { if (row.getCell(colNumber).text !== '') throw new UnprocessableFileDataException( - 'Using inputFile, outputFile is not supported', - filename, - rowNumber + 1 + `Using inputFile, outputFile is not supported: ${rowNumber + 1}`, + filename ) } const title = row.getCell(header['문제제목']).text @@ -174,17 +174,13 @@ export class ProblemService { const languages: Language[] = [] const level: Level = Level['Level' + levelText] const template: Template[] = [] - for (let text of languagesText) { if (text === 'Python') { text = 'Python3' } - if (!(text in Language)) continue - const language = text as keyof typeof Language const code = row.getCell(header[`${language}SampleCode`]).text - template.push({ language, code: [ @@ -197,42 +193,33 @@ export class ProblemService { }) languages.push(Language[language]) } - if (!languages.length) { throw new UnprocessableFileDataException( - 'A problem should support at least one language', - filename, - rowNumber + 1 + `A problem should support at least one language: ${rowNumber + 1}`, + filename ) } - //TODO: specify timeLimit, memoryLimit(default: 2sec, 512mb) - const testCnt = parseInt(row.getCell(header['TestCnt']).text) const inputText = row.getCell(header['Input']).text const outputs = row.getCell(header['Output']).text.split('::') const scoreWeights = row.getCell(header['Score']).text.split('::') - if (testCnt === 0) return - let inputs: string[] = [] if (inputText === '' && testCnt !== 0) { for (let i = 0; i < testCnt; i++) inputs.push('') } else { inputs = inputText.split('::') } - if ( (inputs.length !== testCnt || outputs.length !== testCnt) && inputText != '' ) { throw new UnprocessableFileDataException( - 'TestCnt must match the length of Input and Output. Or Testcases should not include ::.', - filename, - rowNumber + 1 + `TestCnt must match the length of Input and Output. Or Testcases should not include ::. :${rowNumber + 1}`, + filename ) } - const testcaseInput: Testcase[] = [] for (let i = 0; i < testCnt; i++) { testcaseInput.push({ @@ -241,7 +228,6 @@ export class ProblemService { scoreWeight: parseInt(scoreWeights[i]) || undefined }) } - problems.push({ title, description, @@ -260,7 +246,6 @@ export class ProblemService { samples: [] }) }) - return await Promise.all( problems.map(async (data) => { const problem = await this.createProblem(data, userId, groupId) @@ -269,6 +254,92 @@ export class ProblemService { ) } + async uploadImage(input: UploadFileInput, userId: number) { + const { mimetype, createReadStream } = await input.file + const newFilename = randomUUID() + + if (!mimetype.includes('image/')) { + throw new UnprocessableDataException('Only image files can be accepted') + } + + const fileSize = await this.getFileSize(createReadStream()) + try { + await this.storageService.uploadImage( + newFilename, + fileSize, + createReadStream(), + mimetype + ) + await this.prisma.image.create({ + data: { + filename: newFilename, + createdById: userId + } + }) + } catch (error) { + if (error instanceof PrismaClientKnownRequestError) { + await this.storageService.deleteImage(newFilename) // 이미지가 S3에 업로드되었지만, DB에 이미지 정보 등록을 실패한 경우 rollback + } + throw new UnprocessableFileDataException( + 'Error occurred during image upload.', + newFilename + ) + } + + return { + src: + this.config.get('STORAGE_BUCKET_ENDPOINT_URL') + + '/' + + this.config.get('MEDIA_BUCKET_NAME') + + '/' + + newFilename + } + } + + async deleteImage(filename: string, userId: number) { + const image = this.prisma.image.delete({ + where: { + filename, + createdById: userId + } + }) + const s3ImageDeleteResult = this.storageService.deleteImage(filename) + + const [resolvedImage] = await Promise.all([image, s3ImageDeleteResult]) + return resolvedImage + } + + async getFileSize(readStream: ReadStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + + readStream.on('data', (chunk: Buffer) => { + chunks.push(chunk) + + const totalSize = chunks.reduce((acc, chunk) => acc + chunk.length, 0) + if (totalSize > MAX_IMAGE_SIZE) { + readStream.destroy() + reject( + new UnprocessableDataException('File size exceeds maximum limit') + ) + } + }) + + readStream.on('end', () => { + const fileSize = chunks.reduce((acc, chunk) => acc + chunk.length, 0) + resolve(fileSize) + }) + + readStream.on('error', () => { + reject( + new UnprocessableDataException( + 'Error occurred during calculating image size.' + ) + ) + }) + }) + } + async getProblems( input: FilterProblemsInput, groupId: number, @@ -434,16 +505,43 @@ export class ProblemService { } async deleteProblem(id: number, groupId: number) { - await this.getProblem(id, groupId) + const problem = await this.getProblem(id, groupId) const result = await this.prisma.problem.delete({ where: { id } }) await this.storageService.deleteObject(`${id}.json`) + const uuidImageFileNames = this.extractUUIDs(problem.description) + if (uuidImageFileNames) { + await this.prisma.image.deleteMany({ + where: { + filename: { + in: uuidImageFileNames + } + } + }) + + const deleteFromS3Results = uuidImageFileNames.map((filename: string) => { + return this.storageService.deleteImage(filename) + }) + + await Promise.all(deleteFromS3Results) + } + return result } + extractUUIDs(input: string) { + const uuidRegex = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi + const matches = input.match(uuidRegex) + if (!matches) { + return [] + } + return matches + } + async getWorkbookProblems( groupId: number, workbookId: number diff --git a/apps/backend/apps/admin/src/storage/s3.provider.ts b/apps/backend/apps/admin/src/storage/s3.provider.ts index 2048e55b2d..98cd0b91d7 100644 --- a/apps/backend/apps/admin/src/storage/s3.provider.ts +++ b/apps/backend/apps/admin/src/storage/s3.provider.ts @@ -9,7 +9,8 @@ export const S3Provider = { new S3Client({ region: 'ap-northeast-2', // TODO: production 환경에서는 endpoint, forcePathStyle 삭제 - endpoint: config.get('TESTCASE_ENDPOINT_URL'), + // endpoint: config.get('TESTCASE_ENDPOINT_URL'), + endpoint: config.get('STORAGE_BUCKET_ENDPOINT_URL'), forcePathStyle: true, credentials: { accessKeyId: config.get('TESTCASE_ACCESS_KEY') ?? '', @@ -17,3 +18,20 @@ export const S3Provider = { } }) } + +export const S3MediaProvider = { + provide: 'S3_CLIENT_MEDIA', + import: [ConfigModule], + inject: [ConfigService], + useFactory: async (config: ConfigService) => + new S3Client({ + region: 'ap-northeast-2', + // TODO: production 환경에서는 endpoint, forcePathStyle 삭제 + endpoint: config.get('STORAGE_BUCKET_ENDPOINT_URL'), + forcePathStyle: true, + credentials: { + accessKeyId: config.get('MEDIA_ACCESS_KEY') ?? '', + secretAccessKey: config.get('MEDIA_SECRET_KEY') ?? '' + } + }) +} diff --git a/apps/backend/apps/admin/src/storage/storage.module.ts b/apps/backend/apps/admin/src/storage/storage.module.ts index 33a014f8a0..b3e3715b8f 100644 --- a/apps/backend/apps/admin/src/storage/storage.module.ts +++ b/apps/backend/apps/admin/src/storage/storage.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common' import { ConfigModule } from '@nestjs/config' -import { S3Provider } from './s3.provider' +import { S3MediaProvider, S3Provider } from './s3.provider' import { StorageService } from './storage.service' @Module({ imports: [ConfigModule], - providers: [S3Provider, StorageService], + providers: [S3Provider, S3MediaProvider, StorageService], exports: [StorageService] }) export class StorageModule {} diff --git a/apps/backend/apps/admin/src/storage/storage.service.spec.ts b/apps/backend/apps/admin/src/storage/storage.service.spec.ts index e6c391a186..817e0143fa 100644 --- a/apps/backend/apps/admin/src/storage/storage.service.spec.ts +++ b/apps/backend/apps/admin/src/storage/storage.service.spec.ts @@ -1,7 +1,7 @@ import { ConfigService } from '@nestjs/config' import { Test, type TestingModule } from '@nestjs/testing' import { expect } from 'chai' -import { S3Provider } from './s3.provider' +import { S3MediaProvider, S3Provider } from './s3.provider' import { StorageService } from './storage.service' describe('StorageService', () => { @@ -9,7 +9,7 @@ describe('StorageService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [StorageService, S3Provider, ConfigService] + providers: [StorageService, S3Provider, S3MediaProvider, ConfigService] }).compile() service = module.get(StorageService) diff --git a/apps/backend/apps/admin/src/storage/storage.service.ts b/apps/backend/apps/admin/src/storage/storage.service.ts index e176a0fa0e..efe6cc7425 100644 --- a/apps/backend/apps/admin/src/storage/storage.service.ts +++ b/apps/backend/apps/admin/src/storage/storage.service.ts @@ -6,13 +6,15 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' +import type { ReadStream } from 'fs' import { type ContentType, ContentTypes } from './content.type' @Injectable() export class StorageService { constructor( private readonly config: ConfigService, - @Inject('S3_CLIENT') private readonly client: S3Client + @Inject('S3_CLIENT') private readonly client: S3Client, + @Inject('S3_CLIENT_MEDIA') private readonly mediaClient: S3Client ) {} async uploadObject(filename: string, content: string, type: ContentType) { @@ -26,6 +28,23 @@ export class StorageService { ) } + async uploadImage( + filename: string, + fileSize: number, + content: ReadStream, + type: string + ) { + await this.mediaClient.send( + new PutObjectCommand({ + Bucket: this.config.get('MEDIA_BUCKET_NAME'), + Key: filename, + Body: content, + ContentType: type, + ContentLength: fileSize + }) + ) + } + // TODO: uploadFile async readObject(filename: string) { @@ -46,4 +65,13 @@ export class StorageService { }) ) } + + async deleteImage(filename: string) { + await this.mediaClient.send( + new DeleteObjectCommand({ + Bucket: this.config.get('MEDIA_BUCKET_NAME'), + Key: filename + }) + ) + } } diff --git a/apps/backend/apps/client/src/contest/contest.service.spec.ts b/apps/backend/apps/client/src/contest/contest.service.spec.ts index 11d41267a6..a610840d3f 100644 --- a/apps/backend/apps/client/src/contest/contest.service.spec.ts +++ b/apps/backend/apps/client/src/contest/contest.service.spec.ts @@ -216,7 +216,7 @@ describe('ContestService', () => { groupId, user01Id ) - expect(contests).to.have.lengthOf(takeNum) + expect(contests.data).to.have.lengthOf(takeNum) }) it('should return a contest array which starts with id 9', async () => { @@ -228,7 +228,7 @@ describe('ContestService', () => { groupId, user01Id ) - expect(contests[0].id).to.equals(9) + expect(contests.data[0].id).to.equals(9) }) it('a contest should contain following fields', async () => { @@ -238,12 +238,12 @@ describe('ContestService', () => { groupId, user01Id ) - expect(contests[0]).to.have.property('title') - expect(contests[0]).to.have.property('startTime') - expect(contests[0]).to.have.property('endTime') - expect(contests[0]).to.have.property('participants') - expect(contests[0].group).to.have.property('id') - expect(contests[0].group).to.have.property('groupName') + expect(contests.data[0]).to.have.property('title') + expect(contests.data[0]).to.have.property('startTime') + expect(contests.data[0]).to.have.property('endTime') + expect(contests.data[0]).to.have.property('participants') + expect(contests.data[0].group).to.have.property('id') + expect(contests.data[0].group).to.have.property('groupName') }) it("shold return contests whose title contains '낮'", async () => { @@ -255,7 +255,7 @@ describe('ContestService', () => { user01Id, keyword ) - expect(contests.map((contest) => contest.title)).to.deep.equals([ + expect(contests.data.map((contest) => contest.title)).to.deep.equals([ '소프트의 낮' ]) }) @@ -268,9 +268,7 @@ describe('ContestService', () => { 10, groupId ) - const contestIds = contests.finished - .map((c) => c.id) - .sort((a, b) => a - b) + const contestIds = contests.data.map((c) => c.id).sort((a, b) => a - b) const finishedContestIds = [6, 7, 8, 9, 10, 11, 12, 13] expect(contestIds).to.deep.equal(finishedContestIds) }) diff --git a/apps/backend/apps/client/src/contest/contest.service.ts b/apps/backend/apps/client/src/contest/contest.service.ts index e4d99e1d56..9efb873196 100644 --- a/apps/backend/apps/client/src/contest/contest.service.ts +++ b/apps/backend/apps/client/src/contest/contest.service.ts @@ -211,7 +211,23 @@ export class ContestService { orderBy: [{ endTime: 'desc' }, { id: 'desc' }] }) - return this.renameToParticipants(contests) + const total = await this.prisma.contest.count({ + where: { + groupId, + endTime: { + lte: now + }, + id: { + in: registeredContestIds + }, + title: { + contains: search + }, + isVisible: true + } + }) + + return { data: this.renameToParticipants(contests), total } } async getFinishedContestsByGroupId( @@ -239,7 +255,21 @@ export class ContestService { select: contestSelectOption, orderBy: [{ endTime: 'desc' }, { id: 'desc' }] }) - return { finished: this.renameToParticipants(finished) } + + const total = await this.prisma.contest.count({ + where: { + endTime: { + lte: now + }, + groupId, + isVisible: true, + title: { + contains: search + } + } + }) + + return { data: this.renameToParticipants(finished), total } } // TODO: participants 대신 _count.contestRecord 그대로 사용하는 것 고려해보기 diff --git a/apps/backend/apps/client/src/group/group.service.spec.ts b/apps/backend/apps/client/src/group/group.service.spec.ts index 91ccc6a95d..f0548797cf 100644 --- a/apps/backend/apps/client/src/group/group.service.spec.ts +++ b/apps/backend/apps/client/src/group/group.service.spec.ts @@ -133,22 +133,25 @@ describe('GroupService', () => { const cursor = null const res = await service.getGroups(cursor, take) - expect(res).to.deep.equal([ - { - id: 3, - groupName: 'Example Private Group 2', - description: - 'This is an example private group just for testing. Check if this group is not shown to users not registered to this group.', - memberNum: 1 - }, - { - id: 4, - groupName: 'Example Private Group 3', - description: - 'This is an example private group just for testing. Check if this group is not shown to users not registered to this group.', - memberNum: 2 - } - ]) + expect(res).to.deep.equal({ + data: [ + { + id: 3, + groupName: 'Example Private Group 2', + description: + 'This is an example private group just for testing. Check if this group is not shown to users not registered to this group.', + memberNum: 1 + }, + { + id: 4, + groupName: 'Example Private Group 3', + description: + 'This is an example private group just for testing. Check if this group is not shown to users not registered to this group.', + memberNum: 2 + } + ], + total: 2 + }) }) }) diff --git a/apps/backend/apps/client/src/group/group.service.ts b/apps/backend/apps/client/src/group/group.service.ts index 7b276649c0..c6aedc41fd 100644 --- a/apps/backend/apps/client/src/group/group.service.ts +++ b/apps/backend/apps/client/src/group/group.service.ts @@ -156,7 +156,19 @@ export class GroupService { } }) - return groups + const total = await this.prisma.group.count({ + where: { + NOT: { + id: 1 + }, + config: { + path: ['showOnList'], + equals: true + } + } + }) + + return { data: groups, total } } async getJoinedGroups(userId: number) { diff --git a/apps/backend/apps/client/src/notice/notice.service.spec.ts b/apps/backend/apps/client/src/notice/notice.service.spec.ts index 124be0277d..5b8735449c 100644 --- a/apps/backend/apps/client/src/notice/notice.service.spec.ts +++ b/apps/backend/apps/client/src/notice/notice.service.spec.ts @@ -9,6 +9,7 @@ const noticeId = 2 const userId = 1 const groupId = 1 const username = 'manager' +const totalNotice = 24 const notice = { id: noticeId, @@ -55,7 +56,8 @@ const db = { findUnique: stub().resolves(notice), findUniqueOrThrow: stub().resolves(notice), findFirst: stub(), - findFirstOrThrow: stub() + findFirstOrThrow: stub(), + count: stub().resolves(24) }, group: { findUnique: stub().resolves(group) @@ -121,7 +123,10 @@ describe('NoticeService', () => { take: 3, groupId: group.id }) - expect(getNoticesByGroupId).to.deep.equal(userNotices) + expect(getNoticesByGroupId).to.deep.equal({ + data: userNotices, + total: totalNotice + }) }) }) @@ -165,7 +170,11 @@ describe('NoticeService', () => { take: 3, groupId: group.id }) - expect(getFixedNoticesByGroupId).to.deep.equal(userNotices) + + expect(getFixedNoticesByGroupId).to.deep.equal({ + data: userNotices, + total: totalNotice + }) }) }) diff --git a/apps/backend/apps/client/src/notice/notice.service.ts b/apps/backend/apps/client/src/notice/notice.service.ts index 9bf1c0912d..4acb4b86bd 100644 --- a/apps/backend/apps/client/src/notice/notice.service.ts +++ b/apps/backend/apps/client/src/notice/notice.service.ts @@ -47,12 +47,26 @@ export class NoticeService { orderBy: { id: 'desc' } }) - return notices.map((notice) => { + const data = notices.map((notice) => { return { ...notice, createdBy: notice.createdBy?.username } }) + + const total = await this.prisma.notice.count({ + where: { + groupId, + isVisible: true, + isFixed: fixed, + title: { + contains: search, + mode: 'insensitive' + } + } + }) + + return { data, total } } async getNoticeByID(id: number, groupId = OPEN_SPACE_ID) { diff --git a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts index 820293ac9e..8e24c5ba86 100644 --- a/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problem.response.dto.ts @@ -24,4 +24,5 @@ export class ProblemResponseDto { @Expose() acceptedRate: number @Expose() samples: Partial[] @Expose() tags: Partial[] + @Expose() template: JSON[] } diff --git a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts index 4ff6d56408..c299efc744 100644 --- a/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/problems.response.dto.ts @@ -5,7 +5,7 @@ import { Exclude, Expose, Type } from 'class-transformer' export class ProblemsResponseDto { @Expose() @Type(() => Problem) - problems: Problem[] + data: Problem[] @Expose() total: number diff --git a/apps/backend/apps/client/src/problem/dto/related-problems.response.dto.ts b/apps/backend/apps/client/src/problem/dto/related-problems.response.dto.ts index 76880e6468..4ab4d0a2de 100644 --- a/apps/backend/apps/client/src/problem/dto/related-problems.response.dto.ts +++ b/apps/backend/apps/client/src/problem/dto/related-problems.response.dto.ts @@ -5,7 +5,7 @@ import { Exclude, Expose, Transform, Type } from 'class-transformer' export class RelatedProblemsResponseDto { @Expose() @Type(() => Problem) - problems: Problem[] + data: Problem[] @Expose() total: number diff --git a/apps/backend/apps/client/src/problem/problem.repository.ts b/apps/backend/apps/client/src/problem/problem.repository.ts index 7db67429b8..13cbd58853 100644 --- a/apps/backend/apps/client/src/problem/problem.repository.ts +++ b/apps/backend/apps/client/src/problem/problem.repository.ts @@ -50,7 +50,8 @@ export class ProblemRepository { input: true, output: true } - } + }, + template: true } private readonly codeDraftSelectOption = { @@ -78,7 +79,7 @@ export class ProblemRepository { const orderByMapper: Record< ProblemOrder, - Prisma.ProblemOrderByWithRelationAndSearchRelevanceInput[] + Prisma.ProblemOrderByWithRelationInput[] > = { 'id-asc': [{ id: 'asc' }], 'id-desc': [{ id: 'desc' }], diff --git a/apps/backend/apps/client/src/problem/problem.service.spec.ts b/apps/backend/apps/client/src/problem/problem.service.spec.ts index f5650c30de..1cf4223be4 100644 --- a/apps/backend/apps/client/src/problem/problem.service.spec.ts +++ b/apps/backend/apps/client/src/problem/problem.service.spec.ts @@ -163,7 +163,7 @@ describe('ProblemService', () => { // then expect(result).to.deep.equal( plainToInstance(ProblemsResponseDto, { - problems: [ + data: [ { ...mockProblems[0], submissionCount: 10, @@ -275,7 +275,7 @@ describe('ContestProblemService', () => { // then expect(result).to.deep.equal( plainToInstance(RelatedProblemsResponseDto, { - problems: mockContestProblems, + data: mockContestProblems, total: 2 }) ) @@ -303,7 +303,7 @@ describe('ContestProblemService', () => { // then expect(result).to.deep.equal( plainToInstance(RelatedProblemsResponseDto, { - problems: mockContestProblems, + data: mockContestProblems, total: 2 }) ) @@ -491,7 +491,7 @@ describe('WorkbookProblemService', () => { // then expect(result).to.deep.equal( plainToInstance(RelatedProblemsResponseDto, { - problems: mockWorkbookProblems, + data: mockWorkbookProblems, total: 2 }) ) @@ -513,7 +513,7 @@ describe('WorkbookProblemService', () => { // then expect(result).to.deep.equal( plainToInstance(RelatedProblemsResponseDto, { - problems: mockWorkbookProblems, + data: mockWorkbookProblems, total: 2 }) ) diff --git a/apps/backend/apps/client/src/problem/problem.service.ts b/apps/backend/apps/client/src/problem/problem.service.ts index 93a94371c9..779f944062 100644 --- a/apps/backend/apps/client/src/problem/problem.service.ts +++ b/apps/backend/apps/client/src/problem/problem.service.ts @@ -54,7 +54,7 @@ export class ProblemService { ) return plainToInstance(ProblemsResponseDto, { - problems: await Promise.all(problems), + data: await Promise.all(problems), total }) } @@ -105,7 +105,7 @@ export class ContestProblemService { await this.problemRepository.getContestProblemTotalCount(contestId) return plainToInstance(RelatedProblemsResponseDto, { - problems: data, + data, total }) } @@ -164,7 +164,7 @@ export class WorkbookProblemService { await this.problemRepository.getWorkbookProblemTotalCount(workbookId) return plainToInstance(RelatedProblemsResponseDto, { - problems: data, + data, total }) } diff --git a/apps/backend/apps/client/src/submission/enum/submission-order.enum.ts b/apps/backend/apps/client/src/submission/enum/submission-order.enum.ts new file mode 100644 index 0000000000..e9a492c0e0 --- /dev/null +++ b/apps/backend/apps/client/src/submission/enum/submission-order.enum.ts @@ -0,0 +1,8 @@ +export enum SubmissionOrder { + idASC = 'id-asc', + idDESC = 'id-desc', + memoryASC = 'memory-asc', + memoryDESC = 'memory-desc', + cpuTimeASC = 'cpuTime-asc', + cpuTimeDESC = 'cpuTime-desc' +} 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 d784e43da5..0297a9cc85 100644 --- a/apps/backend/apps/client/src/submission/mock/submission.mock.ts +++ b/apps/backend/apps/client/src/submission/mock/submission.mock.ts @@ -1,14 +1,17 @@ import { Language, ResultStatus } from '@prisma/client' import { plainToInstance } from 'class-transformer' import { type CreateSubmissionDto, Snippet } from '../dto/create-submission.dto' +import { submissionResults } from './submissionResult.mock' + +const codes = [ + { id: 1, text: 'code', locked: false }, + { id: 2, text: 'unchanged', locked: true } +] export const submissions = [ { id: 1, - code: [ - { id: 1, text: 'code', locked: false }, - { id: 2, text: 'unchanged', locked: true } - ], + code: codes, result: ResultStatus.Judging, createTime: new Date('2023-01-01'), updateTime: new Date('2023-01-01'), @@ -20,10 +23,7 @@ export const submissions = [ }, { id: 2, - code: [ - { id: 1, text: 'code', locked: false }, - { id: 2, text: 'changed', locked: true } - ], + code: codes, result: ResultStatus.Judging, createTime: new Date('2023-01-01'), updateTime: new Date('2023-01-01'), @@ -32,9 +32,40 @@ export const submissions = [ problemId: 1, contestId: null, workbookId: null + }, + { + id: 3, + code: codes, + result: ResultStatus.Accepted, + createTime: new Date('2023-01-01'), + updateTime: new Date('2023-01-01'), + language: Language.C, + userId: 1, + problemId: 1, + contestId: null, + workbookId: null } ] +export const submissionsWithResult = [ + { + ...submissions[0], + submissionResult: [ + submissionResults[0], + submissionResults[2], + submissionResults[3] + ] + }, + { ...submissions[1], submissionResult: [submissionResults[1]] }, + { ...submissions[2], submissionResult: [submissionResults[0]] } +] + +export const submissionReturns = [ + { ...submissions[0], maxMemoryUsage: 0, maxCpuTime: '0' }, + { ...submissions[1], maxMemoryUsage: 0, maxCpuTime: '0' }, + { ...submissions[2], maxMemoryUsage: 12345, maxCpuTime: '12345' } +] + export const submissionDto: CreateSubmissionDto = { code: plainToInstance(Snippet, submissions[0].code), language: Language.C diff --git a/apps/backend/apps/client/src/submission/submission.controller.ts b/apps/backend/apps/client/src/submission/submission.controller.ts index 9e42f3a6f5..8af2217991 100644 --- a/apps/backend/apps/client/src/submission/submission.controller.ts +++ b/apps/backend/apps/client/src/submission/submission.controller.ts @@ -25,6 +25,7 @@ import { RequiredIntPipe } from '@libs/pipe' import { CreateSubmissionDto } from './dto/create-submission.dto' +import type { SubmissionOrder } from './enum/submission-order.enum' import { SubmissionService } from './submission.service' @Controller('submission') @@ -94,6 +95,7 @@ export class SubmissionController { @Query('cursor', CursorValidationPipe) cursor: number | null, @Query('take', new DefaultValuePipe(10), new RequiredIntPipe('take')) take: number, + @Query('order') order: SubmissionOrder, @Query('problemId', new RequiredIntPipe('problemId')) problemId: number, @Query('groupId', GroupIDPipe) groupId: number ) { @@ -101,6 +103,7 @@ export class SubmissionController { return await this.submissionService.getSubmissions({ cursor, take, + order, problemId, groupId }) diff --git a/apps/backend/apps/client/src/submission/submission.service.spec.ts b/apps/backend/apps/client/src/submission/submission.service.spec.ts index 68800869d9..f097d8acd9 100644 --- a/apps/backend/apps/client/src/submission/submission.service.spec.ts +++ b/apps/backend/apps/client/src/submission/submission.service.spec.ts @@ -18,16 +18,25 @@ import { PrismaService } from '@libs/prisma' import { Snippet } from './dto/create-submission.dto' import type { JudgerResponse } from './dto/judger-response.dto' import { problems } from './mock/problem.mock' -import { submissions, submissionDto } from './mock/submission.mock' +import { + submissions, + submissionDto, + submissionReturns, + submissionsWithResult +} from './mock/submission.mock' import { judgerResponse, submissionResults } from './mock/submissionResult.mock' import { SubmissionService } from './submission.service' const db = { + user: { + findFirst: stub() + }, submission: { findMany: stub(), findFirstOrThrow: stub(), create: stub(), - update: stub() + update: stub(), + count: stub().resolves(1) }, submissionResult: { create: stub() @@ -305,11 +314,13 @@ describe('SubmissionService', () => { describe('getSubmissions', () => { it('should return submissions', async () => { db.problem.findFirstOrThrow.resolves(problems[0]) - db.submission.findMany.resolves(submissions) + db.submission.findMany.resolves(submissionsWithResult) + console.log(submissionsWithResult) + console.log(submissionReturns) expect( await service.getSubmissions({ problemId: problems[0].id }) - ).to.be.deep.equal(submissions) + ).to.be.deep.equal(submissionReturns) }) it('should throw not found error', async () => { @@ -414,6 +425,7 @@ describe('SubmissionService', () => { describe('getContestSubmisssions', () => { it('should return submissions', async () => { + db.user.findFirst.resolves() db.contestRecord.findUniqueOrThrow.resolves() db.contestProblem.findFirstOrThrow.resolves() db.submission.findMany.resolves(submissions) diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index 0cf2fd3dd1..f1f06e1773 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { HttpService } from '@nestjs/axios' import { Injectable, Logger, type OnModuleInit } from '@nestjs/common' import { ConfigService } from '@nestjs/config' @@ -38,6 +39,7 @@ import { } from './dto/create-submission.dto' import { JudgeRequest } from './dto/judge-request.class' import { JudgerResponse } from './dto/judger-response.dto' +import { SubmissionOrder } from './enum/submission-order.enum' @Injectable() export class SubmissionService implements OnModuleInit { @@ -445,14 +447,16 @@ export class SubmissionService implements OnModuleInit { async getSubmissions({ problemId, groupId = OPEN_SPACE_ID, + order = SubmissionOrder.idASC, cursor = null, take = 10 }: { problemId: number groupId?: number + order?: SubmissionOrder cursor?: number | null take?: number - }): Promise[]> { + }) { const paginator = this.prisma.getPaginator(cursor) await this.prisma.problem.findFirstOrThrow({ @@ -481,12 +485,68 @@ export class SubmissionService implements OnModuleInit { createTime: true, language: true, result: true, - codeSize: true + codeSize: true, + submissionResult: { + where: { + result: ResultStatus.Accepted + }, + select: { + result: true, + cpuTime: true, + memoryUsage: true + } + } }, orderBy: [{ id: 'desc' }, { createTime: 'desc' }] }) - return submissions + const result = submissions.map((submission) => { + // 최대 cpuTime과 memoryUsage를 구함 + let maxCpuTime = BigInt(0) + let maxMemoryUsage = 0 + + // 만약 제출 결과가 Accepted가 아닌 경우에는 maxCpuTime과 maxMemoryUsage를 0으로 설정 + if (submission.result === ResultStatus.Accepted) { + submission.submissionResult.forEach((res) => { + if (res.cpuTime > maxCpuTime) { + maxCpuTime = BigInt(res.cpuTime) + } + if (res.memoryUsage > maxMemoryUsage) { + maxMemoryUsage = res.memoryUsage + } + }) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { submissionResult, ...rest } = submission + return { + ...rest, + maxCpuTime: maxCpuTime.toString(), + maxMemoryUsage + } + }) + + result.sort((a, b) => { + switch (order) { + case SubmissionOrder.idASC: + return a.id - b.id + case SubmissionOrder.idDESC: + return b.id - a.id + case SubmissionOrder.memoryASC: + return a.maxMemoryUsage - b.maxMemoryUsage + case SubmissionOrder.memoryDESC: + return b.maxMemoryUsage - a.maxMemoryUsage + case SubmissionOrder.cpuTimeASC: + return Number(BigInt(a.maxCpuTime) - BigInt(b.maxCpuTime)) // Convert BigInt result to Number + case SubmissionOrder.cpuTimeDESC: + return Number(BigInt(b.maxCpuTime) - BigInt(a.maxCpuTime)) // Convert BigInt result to Number + default: + return 0 // Default to no sorting if the order is not recognized + } + }) + + console.log(result) + return result } @Span() @@ -638,7 +698,7 @@ export class SubmissionService implements OnModuleInit { groupId?: number cursor?: number | null take?: number - }): Promise[]> { + }) { const paginator = this.prisma.getPaginator(cursor) const isAdmin = await this.prisma.user.findFirst({ @@ -670,7 +730,7 @@ export class SubmissionService implements OnModuleInit { } }) - return await this.prisma.submission.findMany({ + const submissions = await this.prisma.submission.findMany({ ...paginator, take, where: { @@ -692,5 +752,11 @@ export class SubmissionService implements OnModuleInit { }, orderBy: [{ id: 'desc' }, { createTime: 'desc' }] }) + + const total = await this.prisma.submission.count({ + where: { problemId, contestId } + }) + + return { data: submissions, total } } } diff --git a/apps/backend/apps/client/src/user/user.service.ts b/apps/backend/apps/client/src/user/user.service.ts index 283961a50a..d215092d15 100644 --- a/apps/backend/apps/client/src/user/user.service.ts +++ b/apps/backend/apps/client/src/user/user.service.ts @@ -12,7 +12,7 @@ import { generate } from 'generate-password' import { ExtractJwt } from 'passport-jwt' import { type AuthenticatedRequest, JwtAuthService } from '@libs/auth' import { emailAuthenticationPinCacheKey } from '@libs/cache' -import { EMAIL_AUTH_EXPIRE_TIME } from '@libs/constants' +import { EMAIL_AUTH_EXPIRE_TIME, ARGON2_HASH_OPTION } from '@libs/constants' import { ConflictFoundException, DuplicateFoundException, @@ -186,7 +186,7 @@ export class UserService { email }, data: { - password: await hash(newPassword) + password: await hash(newPassword, ARGON2_HASH_OPTION) } }) this.logger.debug(user, 'updateUserPasswordInPrisma') @@ -351,7 +351,7 @@ export class UserService { } async createUser(signUpDto: SignUpDto): Promise { - const encryptedPassword = await hash(signUpDto.password) + const encryptedPassword = await hash(signUpDto.password, ARGON2_HASH_OPTION) const user = await this.prisma.user.create({ data: { diff --git a/apps/backend/apps/client/src/workbook/workbook.service.spec.ts b/apps/backend/apps/client/src/workbook/workbook.service.spec.ts index ebe81c5929..3941fe08ef 100644 --- a/apps/backend/apps/client/src/workbook/workbook.service.spec.ts +++ b/apps/backend/apps/client/src/workbook/workbook.service.spec.ts @@ -107,7 +107,8 @@ const db = { findFirst: stub(), create: stub(), update: stub(), - delete: stub() + delete: stub(), + count: stub().resolves(3) }, workbookProblem: { findMany: stub() @@ -137,7 +138,10 @@ describe('WorkbookService', () => { 0, 3 ) - expect(returnedPublicWorkbooks).to.deep.equal(visiblePublicWorkbooks) + expect(returnedPublicWorkbooks).to.deep.equal({ + data: visiblePublicWorkbooks, + total: 3 + }) }) it('get a list of private group workbooks', async () => { @@ -148,7 +152,10 @@ describe('WorkbookService', () => { 3, PRIVATE_GROUP_ID ) - expect(returnedGroupWorkbooks).to.deep.equal(groupWorkbooks) + expect(returnedGroupWorkbooks).to.deep.equal({ + data: groupWorkbooks, + total: 3 + }) }) it('get details of a workbook (user)', async () => { diff --git a/apps/backend/apps/client/src/workbook/workbook.service.ts b/apps/backend/apps/client/src/workbook/workbook.service.ts index 8abe5e4391..b25507d368 100644 --- a/apps/backend/apps/client/src/workbook/workbook.service.ts +++ b/apps/backend/apps/client/src/workbook/workbook.service.ts @@ -29,7 +29,15 @@ export class WorkbookService { updateTime: true } }) - return workbooks + + const total = await this.prisma.workbook.count({ + where: { + groupId, + isVisible: true + } + }) + + return { data: workbooks, total } } async getWorkbook( diff --git a/apps/backend/libs/auth/src/roles/group-member.guard.ts b/apps/backend/libs/auth/src/roles/group-member.guard.ts index 762eeca687..9a26da8c0d 100644 --- a/apps/backend/libs/auth/src/roles/group-member.guard.ts +++ b/apps/backend/libs/auth/src/roles/group-member.guard.ts @@ -31,7 +31,7 @@ export class GroupMemberGuard implements CanActivate { : parseInt(request.query.groupId as string) } - if (groupId === OPEN_SPACE_ID) { + if (!request.user && groupId === OPEN_SPACE_ID) { return true } @@ -40,7 +40,7 @@ export class GroupMemberGuard implements CanActivate { const userRole = (await this.service.getUserRole(user.id)).role user.role = userRole } - if (user.isAdmin() || user.isSuperAdmin()) { + if (user.isAdmin() || user.isSuperAdmin() || groupId === OPEN_SPACE_ID) { return true } diff --git a/apps/backend/libs/constants/src/argon2.constants.ts b/apps/backend/libs/constants/src/argon2.constants.ts new file mode 100644 index 0000000000..edc4da8e7b --- /dev/null +++ b/apps/backend/libs/constants/src/argon2.constants.ts @@ -0,0 +1,5 @@ +export const ARGON2_HASH_OPTION = { + timeCost: 2, + memoryCost: 2 ** 11, + parallelism: 1 +} diff --git a/apps/backend/libs/constants/src/index.ts b/apps/backend/libs/constants/src/index.ts index c7bfca4744..a657100fb6 100644 --- a/apps/backend/libs/constants/src/index.ts +++ b/apps/backend/libs/constants/src/index.ts @@ -2,3 +2,4 @@ export * from './oauth.constants' export * from './time.constants' export * from './rabbitmq.constants' export * from './submission.constants' +export * from './argon2.constants' diff --git a/apps/backend/libs/constants/src/time.constants.ts b/apps/backend/libs/constants/src/time.constants.ts index ed447c0e6e..2945da5883 100644 --- a/apps/backend/libs/constants/src/time.constants.ts +++ b/apps/backend/libs/constants/src/time.constants.ts @@ -23,3 +23,8 @@ export const INVIATION_EXPIRE_TIME = 14 * SECONDS_PER_DAY * 1000 export const OPEN_SPACE_ID = 1 export const PUBLICIZING_REQUEST_KEY = 'publicize' + +/** Image Size Limitation */ +const KILOBYTE = 1024 +const MEGABYTE = 1024 * KILOBYTE +export const MAX_IMAGE_SIZE = 5 * MEGABYTE diff --git a/apps/backend/libs/exception/src/business.exception.ts b/apps/backend/libs/exception/src/business.exception.ts index 2ddbcd17e3..b5a88de540 100644 --- a/apps/backend/libs/exception/src/business.exception.ts +++ b/apps/backend/libs/exception/src/business.exception.ts @@ -78,8 +78,8 @@ export class UnprocessableDataException extends BusinessException { /** [422] Throw when file data is invalid or cannot be processed. */ export class UnprocessableFileDataException extends UnprocessableDataException { - constructor(message, fileName, rowNumber) { - super(`${message} @${fileName}:${rowNumber}`) + constructor(message, fileName) { + super(`${message} @${fileName}`) } } diff --git a/apps/backend/libs/logger/src/pino-option.logger.ts b/apps/backend/libs/logger/src/pino-option.logger.ts index 8e9efa27cc..65bfccc8c5 100644 --- a/apps/backend/libs/logger/src/pino-option.logger.ts +++ b/apps/backend/libs/logger/src/pino-option.logger.ts @@ -63,6 +63,6 @@ export const pinoLoggerModuleOption: Params = { return req } }, - redact: ['req.body.password'] + redact: ['req.body.password', 'req.body.passwordAgain'] } } diff --git a/apps/backend/package.json b/apps/backend/package.json index ca8793ae73..45831e5570 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,40 +19,40 @@ }, "dependencies": { "@apollo/server": "^4.10.4", - "@aws-sdk/client-s3": "^3.572.0", - "@aws-sdk/client-ses": "^3.572.0", - "@aws-sdk/credential-provider-node": "^3.572.0", + "@aws-sdk/client-s3": "^3.608.0", + "@aws-sdk/client-ses": "^3.606.0", + "@aws-sdk/credential-provider-node": "^3.600.0", "@golevelup/nestjs-rabbitmq": "^5.3.0", "@nestjs-modules/mailer": "^2.0.2", - "@nestjs/apollo": "^12.1.0", + "@nestjs/apollo": "^12.2.0", "@nestjs/axios": "^3.0.2", "@nestjs/cache-manager": "^2.2.2", - "@nestjs/common": "^10.3.8", - "@nestjs/config": "^3.2.2", - "@nestjs/core": "^10.3.8", - "@nestjs/graphql": "^12.1.1", + "@nestjs/common": "^10.3.10", + "@nestjs/config": "^3.2.3", + "@nestjs/core": "^10.3.10", + "@nestjs/graphql": "^12.2.0", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", - "@nestjs/platform-express": "^10.3.8", - "@nestjs/swagger": "^7.3.1", - "@opentelemetry/api": "~1.8.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.51.1", - "@opentelemetry/exporter-trace-otlp-http": "^0.51.1", - "@opentelemetry/host-metrics": "^0.35.1", - "@opentelemetry/instrumentation-express": "^0.38.0", - "@opentelemetry/instrumentation-http": "^0.51.1", - "@opentelemetry/resources": "^1.24.1", - "@opentelemetry/sdk-metrics": "^1.24.1", - "@opentelemetry/sdk-node": "^0.51.1", - "@opentelemetry/sdk-trace-node": "^1.24.1", - "@opentelemetry/semantic-conventions": "^1.24.1", - "@prisma/client": "^5.13.0", - "@prisma/instrumentation": "~5.13.0", - "argon2": "^0.40.1", - "axios": "^1.6.8", - "cache-manager": "^5.5.2", - "cache-manager-redis-yet": "^5.0.0", - "chai-exclude": "^2.1.0", + "@nestjs/platform-express": "^10.3.10", + "@nestjs/swagger": "^7.4.0", + "@opentelemetry/api": "~1.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.52.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.52.1", + "@opentelemetry/host-metrics": "^0.35.2", + "@opentelemetry/instrumentation-express": "^0.40.1", + "@opentelemetry/instrumentation-http": "^0.52.1", + "@opentelemetry/resources": "^1.25.1", + "@opentelemetry/sdk-metrics": "^1.25.1", + "@opentelemetry/sdk-node": "^0.52.1", + "@opentelemetry/sdk-trace-node": "^1.25.1", + "@opentelemetry/semantic-conventions": "^1.25.1", + "@prisma/client": "^5.16.1", + "@prisma/instrumentation": "~5.16.1", + "argon2": "^0.40.3", + "axios": "^1.7.2", + "cache-manager": "^5.6.1", + "cache-manager-redis-yet": "^5.1.2", + "chai-exclude": "^2.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "colorette": "^2.0.20", @@ -61,53 +61,53 @@ "dayjs": "^1.11.11", "exceljs": "^4.4.0", "generate-password": "^1.7.1", - "graphql": "^16.8.1", + "graphql": "^16.9.0", "graphql-type-json": "^0.3.2", "graphql-upload": "13.0.0", "handlebars": "^4.7.8", "nestjs-otel": "^5.1.5", - "nestjs-pino": "^4.0.0", - "nodemailer": "^6.9.13", + "nestjs-pino": "^4.1.0", + "nodemailer": "^6.9.14", "passport": "^0.7.0", "passport-github2": "^0.1.12", "passport-jwt": "^4.0.1", "passport-kakao": "^1.0.1", - "pino-http": "^9.0.0", - "pino-pretty": "^11.0.0", + "pino-http": "^10.1.0", + "pino-pretty": "^11.2.1", "reflect-metadata": "^0.2.1", - "sql-formatter": "^15.3.1", + "sql-formatter": "^15.3.2", "zod": "^3.23.8" }, "devDependencies": { "@faker-js/faker": "^8.4.1", "@istanbuljs/nyc-config-typescript": "^1.0.2", - "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.1", - "@nestjs/testing": "^10.3.8", + "@nestjs/cli": "^10.4.0", + "@nestjs/schematics": "^10.1.2", + "@nestjs/testing": "^10.3.10", "@types/cache-manager": "^4.0.6", "@types/chai": "^4.3.16", "@types/chai-as-promised": "^7.1.8", "@types/express": "^4.17.21", "@types/graphql-upload": "8.0.12", - "@types/mocha": "^10.0.6", - "@types/node": "^20.12.11", + "@types/mocha": "^10.0.7", + "@types/node": "^20.14.9", "@types/nodemailer": "^6.4.15", "@types/passport-jwt": "^4.0.1", "@types/proxyquire": "^1.3.31", "@types/sinon": "^17.0.3", "chai": "^4.4.1", - "chai-as-promised": "^7.1.1", - "mocha": "^10.4.0", - "nyc": "^15.1.0", - "prisma": "^5.13.0", - "prisma-nestjs-graphql": "^20.0.2", + "chai-as-promised": "^7.1.2", + "mocha": "^10.5.2", + "nyc": "^17.0.0", + "prisma": "^5.16.1", + "prisma-nestjs-graphql": "^20.0.3", "proxyquire": "^2.1.3", - "sinon": "^17.0.2", + "sinon": "^18.0.0", "source-map-support": "^0.5.21", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.4.5" + "typescript": "^5.5.3" }, "prisma": { "seed": "ts-node prisma/seed.ts" diff --git a/apps/backend/prisma/migrations/20240406140833_add_image_model/migration.sql b/apps/backend/prisma/migrations/20240406140833_add_image_model/migration.sql new file mode 100644 index 0000000000..6c3bfce9bd --- /dev/null +++ b/apps/backend/prisma/migrations/20240406140833_add_image_model/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "Image" ( + "filename" TEXT NOT NULL, + "createdById" INTEGER, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Image_pkey" PRIMARY KEY ("filename") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Image_filename_key" ON "Image"("filename"); + +-- AddForeignKey +ALTER TABLE "Image" ADD CONSTRAINT "Image_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index b96f9b6093..a1afc8030d 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -58,6 +58,7 @@ model User { submission Submission[] useroauth UserOAuth? CodeDraft CodeDraft[] + Image Image[] @@map("user") } @@ -193,6 +194,13 @@ model Problem { @@map("problem") } +model Image { + filename String @id @unique + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + createdById Int? + createTime DateTime @default(now()) @map("create_time") +} + enum Level { Level1 Level2 diff --git a/apps/frontend/__tests__/components.test.tsx b/apps/frontend/__tests__/components.test.tsx new file mode 100644 index 0000000000..e0a9e4b157 --- /dev/null +++ b/apps/frontend/__tests__/components.test.tsx @@ -0,0 +1,24 @@ +import ContestCard from '@/app/(main)/_components/ContestCard' +import { render, screen } from '@testing-library/react' +import { expect, test } from 'vitest' + +test('ContestCard', () => { + render( + + ) + expect(screen.getByText('test')).toBeDefined() + expect(screen.getByText('ongoing')).toBeDefined() +}) diff --git a/apps/frontend/__tests__/utils.test.ts b/apps/frontend/__tests__/utils.test.ts new file mode 100644 index 0000000000..61029133ed --- /dev/null +++ b/apps/frontend/__tests__/utils.test.ts @@ -0,0 +1,15 @@ +import { convertToLetter, dateFormatter } from '@/lib/utils' +import { test, expect } from 'vitest' + +test('convertToLetter', () => { + expect(convertToLetter(0)).toBe('A') + expect(convertToLetter(1)).toBe('B') + expect(convertToLetter(2)).toBe('C') + expect(convertToLetter(25)).toBe('Z') +}) + +test('dateFormatter', () => { + expect(dateFormatter('2022-01-01', 'YYYY-MM-DD')).toBe('2022-01-01') + expect(dateFormatter('2022-01-01', 'DD/MM/YYYY')).toBe('01/01/2022') + expect(dateFormatter('2022-01-01', 'DD MMM YYYY')).toBe('01 Jan 2022') +}) diff --git a/apps/frontend/app/(main)/_components/ProblemCards.tsx b/apps/frontend/app/(main)/_components/ProblemCards.tsx index bba43d03a1..cda38c06fb 100644 --- a/apps/frontend/app/(main)/_components/ProblemCards.tsx +++ b/apps/frontend/app/(main)/_components/ProblemCards.tsx @@ -4,8 +4,13 @@ import type { Route } from 'next' import Link from 'next/link' import ProblemCard from './ProblemCard' +interface ProblemCardsProps { + data: WorkbookProblem[] + total: number +} + const getProblems = async () => { - const { problems }: { problems: WorkbookProblem[] } = await fetcher + const problems: ProblemCardsProps = await fetcher .get('problem', { searchParams: { take: 3 @@ -13,7 +18,8 @@ const getProblems = async () => { } }) .json() - return problems + + return problems.data } export default async function ProblemCards() { diff --git a/apps/frontend/app/(main)/contest/[contestId]/@tabs/problem/page.tsx b/apps/frontend/app/(main)/contest/[contestId]/@tabs/problem/page.tsx index 33c2e870bd..63059a587a 100644 --- a/apps/frontend/app/(main)/contest/[contestId]/@tabs/problem/page.tsx +++ b/apps/frontend/app/(main)/contest/[contestId]/@tabs/problem/page.tsx @@ -8,6 +8,11 @@ interface ContestProblemProps { params: { contestId: string } } +interface ContestApiResponse { + data: ContestProblem[] + total: number +} + export default async function ContestProblem({ params }: ContestProblemProps) { const { contestId } = params const res = await fetcherWithAuth.get(`contest/${contestId}/problem`, { @@ -34,10 +39,11 @@ export default async function ContestProblem({ params }: ContestProblemProps) { ) } - const { problems }: { problems: ContestProblem[] } = await res.json() + const problems: ContestApiResponse = await res.json() + return ( -
+
{currentPage}
{ + ContestData.data.forEach((contest) => { contest.status = 'finished' }) @@ -17,7 +21,7 @@ export default async function FinishedContestTable() {

Finished

{/* TODO: Add search bar */} | Notice[] = - search !== '' - ? [] - : fetcher - .get('notice', { - searchParams: { - fixed: 'true', - take: '10' - } - }) - .json() - - const noticesFetcher: Promise = fetcher +interface NoticeProps { + data: Notice[] + total: number +} + +const getFixedNotices = async () => { + const notices: NoticeProps = await fetcher + .get('notice', { + searchParams: { + fixed: 'true', + take: '10' + } + }) + .json() + + return notices.data +} + +const getNotices = async (search: string) => { + const notices: NoticeProps = await fetcher .get('notice', { searchParams: { search, @@ -29,10 +35,22 @@ export default async function NoticeTable({ search }: Props) { }) .json() + return notices.data +} + +export default async function NoticeTable({ search }: Props) { + const fixedNoticesFetcher: Promise | Notice[] = + search !== '' ? [] : getFixedNotices() + + const noticesFetcher: Promise = getNotices(search) + const [fixedNotices, notices] = await Promise.all([ fixedNoticesFetcher, noticesFetcher ]) + + fixedNotices == undefined ? [] : fixedNotices + const currentPageData = fixedNotices.concat(notices) return ( diff --git a/apps/frontend/app/admin/contest/[id]/page.tsx b/apps/frontend/app/admin/contest/[id]/page.tsx index 9366d8d4b4..e93db60f68 100644 --- a/apps/frontend/app/admin/contest/[id]/page.tsx +++ b/apps/frontend/app/admin/contest/[id]/page.tsx @@ -1,12 +1,22 @@ 'use client' -import { gql } from '@generated' import { DataTableAdmin } from '@/components/DataTableAdmin' import TextEditor from '@/components/TextEditor' import { DateTimePickerDemo } from '@/components/date-time-picker-demo' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' +import { + IMPORT_PROBLEMS_TO_CONTEST, + UPDATE_CONTEST, + REMOVE_PROBLEMS_FROM_CONTEST +} from '@/graphql/contest/mutations' +import { GET_CONTEST } from '@/graphql/contest/queries' +import { + UPDATE_PROBLEM_VISIBLE, + UPDATE_CONTEST_PROBLEMS_ORDER +} from '@/graphql/problem/mutations' +import { GET_CONTEST_PROBLEMS } from '@/graphql/problem/queries' import { cn } from '@/lib/utils' import { useMutation, useQuery } from '@apollo/client' import type { UpdateContestInput } from '@generated/graphql' @@ -24,98 +34,6 @@ import { z } from 'zod' import Label from '../_components/Label' import { columns } from './_components/Columns' -const GET_CONTEST = gql(` - query GetContest($contestId: Int!) { - getContest(contestId: $contestId) { - id - description - endTime - startTime - title - } - } -`) - -const UPDATE_CONTEST = gql(` - mutation UpdateContest($groupId: Int!, $input: UpdateContestInput!) { - updateContest(groupId: $groupId, input: $input) { - id - isRankVisible - isVisible - description - endTime - startTime - title - } - } -`) - -const GET_CONTEST_PROBLEMS = gql(` - query GetContestProblems($groupId: Int!, $contestId: Int!) { - getContestProblems(groupId: $groupId, contestId: $contestId) { - order - problemId - problem { - title - difficulty - } - } - } -`) - -const IMPORT_PROBLEMS_TO_CONTEST = gql(` - mutation ImportProblemsToContest( - $groupId: Int!, - $contestId: Int!, - $problemIds: [Int!]! - ) { - importProblemsToContest( - groupId: $groupId, - contestId: $contestId, - problemIds: $problemIds - ) { - contestId - problemId - } - } -`) - -const REMOVE_PROBLEMS_FROM_CONTEST = gql(` - mutation RemoveProblemsFromContest( - $groupId: Int!, - $contestId: Int!, - $problemIds: [Int!]! - ) { - removeProblemsFromContest( - groupId: $groupId, - contestId: $contestId, - problemIds: $problemIds - ) { - contestId - problemId - } - } -`) - -const UPDATE_CONTEST_PROBLEMS_ORDER = gql(` - mutation UpdateContestProblemsOrder($groupId: Int!, $contestId: Int!, $orders: [Int!]!) { - updateContestProblemsOrder(groupId: $groupId, contestId: $contestId, orders: $orders) { - order - contestId - problemId - } - } -`) - -const EDIT_VISIBLE = gql(` -mutation UpdateVisible($groupId: Int!, $input: UpdateProblemInput!) { - updateProblem(groupId: $groupId, input: $input) { - id - isVisible - } -} -`) - const inputStyle = 'border-gray-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-950' @@ -230,7 +148,7 @@ export default function Page({ params }: { params: { id: string } }) { const [updateContest, { error }] = useMutation(UPDATE_CONTEST) const [importProblemsToContest] = useMutation(IMPORT_PROBLEMS_TO_CONTEST) const [removeProblemsFromContest] = useMutation(REMOVE_PROBLEMS_FROM_CONTEST) - const [updateVisible] = useMutation(EDIT_VISIBLE) + const [updateVisible] = useMutation(UPDATE_PROBLEM_VISIBLE) const [updateContestProblemsOrder] = useMutation( UPDATE_CONTEST_PROBLEMS_ORDER ) diff --git a/apps/frontend/app/admin/contest/_components/Columns.tsx b/apps/frontend/app/admin/contest/_components/Columns.tsx index 5933ccf02f..7d03fdac20 100644 --- a/apps/frontend/app/admin/contest/_components/Columns.tsx +++ b/apps/frontend/app/admin/contest/_components/Columns.tsx @@ -1,6 +1,5 @@ 'use client' -import { gql } from '@generated' import { DataTableColumnHeader } from '@/components/DataTableColumnHeader' import { Checkbox } from '@/components/ui/checkbox' import { Switch } from '@/components/ui/switch' @@ -10,6 +9,7 @@ import { TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { UPDATE_CONTEST_VISIBLE } from '@/graphql/contest/mutations' import { cn, dateFormatter } from '@/lib/utils' import { useMutation } from '@apollo/client' import * as TooltipPrimitive from '@radix-ui/react-tooltip' @@ -28,18 +28,8 @@ interface DataTableContest { isRankVisible: boolean } -const EDIT_VISIBLE = gql(` - mutation UpdateContestVisible($groupId: Int!, $input: UpdateContestInput!) { - updateContest(groupId: $groupId, input: $input) { - id - isVisible - isRankVisible - } - } -`) - function VisibleCell({ row }: { row: Row }) { - const [updateVisible] = useMutation(EDIT_VISIBLE) + const [updateVisible] = useMutation(UPDATE_CONTEST_VISIBLE) return (
diff --git a/apps/frontend/app/admin/contest/create/page.tsx b/apps/frontend/app/admin/contest/create/page.tsx index a7cb652cdc..0ebc98b3fe 100644 --- a/apps/frontend/app/admin/contest/create/page.tsx +++ b/apps/frontend/app/admin/contest/create/page.tsx @@ -1,6 +1,5 @@ 'use client' -import { gql } from '@generated' import { DataTableAdmin } from '@/components/DataTableAdmin' import TextEditor from '@/components/TextEditor' import { DateTimePickerDemo } from '@/components/date-time-picker-demo' @@ -12,6 +11,14 @@ import { PopoverTrigger } from '@/components/ui/popover' import { ScrollArea } from '@/components/ui/scroll-area' +import { + CREATE_CONTEST, + IMPORT_PROBLEMS_TO_CONTEST +} from '@/graphql/contest/mutations' +import { + UPDATE_PROBLEM_VISIBLE, + UPDATE_CONTEST_PROBLEMS_ORDER +} from '@/graphql/problem/mutations' import { cn } from '@/lib/utils' import { useMutation } from '@apollo/client' import type { CreateContestInput } from '@generated/graphql' @@ -30,56 +37,6 @@ import { z } from 'zod' import Label from '../_components/Label' import { columns } from './_components/Columns' -const CREATE_CONTEST = gql(` - mutation CreateContest($groupId: Int!, $input: CreateContestInput!) { - createContest(groupId: $groupId, input: $input) { - id - isVisible - isRankVisible - description - endTime - startTime - title - } - } -`) - -const IMPORT_PROBLEMS_TO_CONTEST = gql(` - mutation ImportProblemsToContest( - $groupId: Int!, - $contestId: Int!, - $problemIds: [Int!]! - ) { - importProblemsToContest( - groupId: $groupId, - contestId: $contestId, - problemIds: $problemIds - ) { - contestId - problemId - } - } -`) - -const UPDATE_CONTEST_PROBLEMS_ORDER = gql(` - mutation UpdateContestProblemsOrder($groupId: Int!, $contestId: Int!, $orders: [Int!]!) { - updateContestProblemsOrder(groupId: $groupId, contestId: $contestId, orders: $orders) { - order - contestId - problemId - } - } -`) - -const EDIT_VISIBLE = gql(` - mutation UpdateVisible($groupId: Int!, $input: UpdateProblemInput!) { - updateProblem(groupId: $groupId, input: $input) { - id - isVisible - } - } -`) - const inputStyle = 'border-gray-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-950' @@ -123,7 +80,7 @@ export default function Page() { const [createContest, { error }] = useMutation(CREATE_CONTEST) const [importProblemsToContest] = useMutation(IMPORT_PROBLEMS_TO_CONTEST) - const [updateVisible] = useMutation(EDIT_VISIBLE) + const [updateVisible] = useMutation(UPDATE_PROBLEM_VISIBLE) const [updateContestProblemsOrder] = useMutation( UPDATE_CONTEST_PROBLEMS_ORDER ) @@ -397,7 +354,7 @@ export default function Page() {
)} -
-
+
-