From 62364c21c002d8d15fb82a2fc1bb2c3e5542d866 Mon Sep 17 00:00:00 2001 From: Jaemin Choi <1dotolee@gmail.com> Date: Tue, 13 Feb 2024 16:06:20 +0900 Subject: [PATCH 01/14] fix(fe): fix broken main page api (#1369) * fix(fe): fix broken main page api * fix(fe): fix all problem api responses --- frontend-client/app/(main)/_components/ProblemCards.tsx | 2 +- .../app/(main)/contest/[id]/@tabs/problem/page.tsx | 7 ++++--- .../app/(main)/problem/_components/ProblemTable.tsx | 2 +- frontend-client/app/admin/problem/page.tsx | 4 +--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/frontend-client/app/(main)/_components/ProblemCards.tsx b/frontend-client/app/(main)/_components/ProblemCards.tsx index 344f18f143..2561015eb5 100644 --- a/frontend-client/app/(main)/_components/ProblemCards.tsx +++ b/frontend-client/app/(main)/_components/ProblemCards.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import ProblemCard from './ProblemCard' const getProblems = async () => { - const problems: WorkbookProblem[] = await fetcher + const { problems }: { problems: WorkbookProblem[] } = await fetcher .get('problem', { searchParams: { take: 3, diff --git a/frontend-client/app/(main)/contest/[id]/@tabs/problem/page.tsx b/frontend-client/app/(main)/contest/[id]/@tabs/problem/page.tsx index cd239e2f79..115a743e72 100644 --- a/frontend-client/app/(main)/contest/[id]/@tabs/problem/page.tsx +++ b/frontend-client/app/(main)/contest/[id]/@tabs/problem/page.tsx @@ -9,7 +9,7 @@ interface ContestProblemProps { export default async function ContestProblem({ params }: ContestProblemProps) { const { id } = params - const contestProblems: ContestProblem[] = await fetcher + const { problems }: { problems: ContestProblem[] } = await fetcher .get('problem', { searchParams: { take: 10, @@ -17,13 +17,14 @@ export default async function ContestProblem({ params }: ContestProblemProps) { } }) .json() - contestProblems.forEach((problem) => { + + problems.forEach((problem) => { problem.id = problem.problemId }) return ( From 1089c44dfc2b4805b4f4f8d192decc6b9e49269a Mon Sep 17 00:00:00 2001 From: wonhyeok <64388458+Lee-won-hyeok@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:24:31 +0900 Subject: [PATCH 02/14] feat(be): add registered finished contests in get contest/auth API (#1283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add registered finished contests in get contest/auth API * feat: add contest APIs * feat: add searching on Contest Get APIs * fix: change Registered Contest GET API where option registered finished contests를 isVisible일 때만 조회 가능하도록 변경 * refactor: change parameter sequences for readability * fix: delete unused error guard * docs: update contest client api * docs: change asserts * fix: update return type * fix: change argument type * test: add contestRecord seed data user01 사용자를 contest id가 홀수인 모든 contest에 참가하도록 수정 * fix: make test code to be run on test DB * docs: add minor descriptions --------- Co-authored-by: Peorge --- .../client/src/contest/contest.controller.ts | 75 ++- .../src/contest/contest.service.spec.ts | 565 ++++++++---------- .../client/src/contest/contest.service.ts | 185 ++++-- backend/prisma/seed.ts | 34 +- .../Contest/Get finished contests/Succeed.bru | 9 +- .../[403] Not a Member of the Group.bru | 19 - .../Succeed.bru | 25 +- .../[401] Unauthorized.bru | 19 - .../Succeed.bru | 13 +- .../[403] Not a Member of the Group.bru | 19 - .../Succeed.bru | 50 ++ .../Succeed.bru | 44 ++ 12 files changed, 568 insertions(+), 489 deletions(-) delete mode 100644 collection/client/Contest/Get finished contests/[403] Not a Member of the Group.bru delete mode 100644 collection/client/Contest/Get ongoing & upcoming contests (logged in)/[401] Unauthorized.bru delete mode 100644 collection/client/Contest/Get ongoing & upcoming contests/[403] Not a Member of the Group.bru create mode 100644 collection/client/Contest/Get registered finished contests/Succeed.bru create mode 100644 collection/client/Contest/Get registered ongoing & upcoming contests/Succeed.bru diff --git a/backend/apps/client/src/contest/contest.controller.ts b/backend/apps/client/src/contest/contest.controller.ts index 60031356f9..7ca3b7f7f4 100644 --- a/backend/apps/client/src/contest/contest.controller.ts +++ b/backend/apps/client/src/contest/contest.controller.ts @@ -36,33 +36,29 @@ export class ContestController { constructor(private readonly contestService: ContestService) {} - @Get() + @Get('ongoing-upcoming') @AuthNotNeededIfOpenSpace() - async getContests( - @Req() req: AuthenticatedRequest, + async getOngoingUpcomingContests( @Query('groupId', GroupIDPipe) groupId: number ) { try { - return await this.contestService.getContestsByGroupId( - req.user?.id, - groupId - ) + return await this.contestService.getContestsByGroupId(groupId) } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.name === 'NotFoundError' - ) { - throw new NotFoundException(error.message) - } this.logger.error(error) throw new InternalServerErrorException() } } - @Get('auth') - async authGetContests(@Req() req: AuthenticatedRequest) { + @Get('ongoing-upcoming-with-registered') + async getOngoingUpcomingContestsWithRegistered( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number + ) { try { - return await this.contestService.getContestsByGroupId(req.user.id) + return await this.contestService.getContestsByGroupId( + groupId, + req.user.id + ) } catch (error) { this.logger.error(error) throw new InternalServerErrorException() @@ -75,13 +71,56 @@ export class ContestController { @Query('groupId', GroupIDPipe) groupId: number, @Query('cursor', CursorValidationPipe) cursor: number | null, @Query('take', new DefaultValuePipe(10), new RequiredIntPipe('take')) - take: number + take: number, + @Query('search') search?: string ) { try { return await this.contestService.getFinishedContestsByGroupId( cursor, take, - groupId + groupId, + search + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('registered-finished') + async getRegisteredFinishedContests( + @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.contestService.getRegisteredFinishedContests( + cursor, + take, + groupId, + req.user.id, + search + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('registered-ongoing-upcoming') + async getRegisteredOngoingUpcomingContests( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Query('search') search?: string + ) { + try { + return await this.contestService.getRegisteredOngoingUpcomingContests( + groupId, + req.user.id, + search ) } catch (error) { this.logger.error(error) diff --git a/backend/apps/client/src/contest/contest.service.spec.ts b/backend/apps/client/src/contest/contest.service.spec.ts index e3eb4b0308..023c7104b8 100644 --- a/backend/apps/client/src/contest/contest.service.spec.ts +++ b/backend/apps/client/src/contest/contest.service.spec.ts @@ -1,32 +1,30 @@ +import { ConfigService } from '@nestjs/config' import { Test, type TestingModule } from '@nestjs/testing' import { Prisma, type Contest, - type ContestRecord, - type Group + type Group, + type ContestRecord } from '@prisma/client' -import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' import { expect } from 'chai' import * as dayjs from 'dayjs' -import { stub } from 'sinon' import { ConflictFoundException, EntityNotExistException, ForbiddenAccessException } from '@libs/exception' import { PrismaService } from '@libs/prisma' -import { type ContestSelectResult, ContestService } from './contest.service' +import { ContestService, type ContestResult } from './contest.service' const contestId = 1 -const userId = 1 +const user01Id = 4 const groupId = 1 -const undefinedUserId = undefined const now = dayjs() const contest = { id: contestId, - createdById: userId, + createdById: 1, groupId, title: 'title', description: 'description', @@ -45,24 +43,6 @@ const contest = { } satisfies Contest & { group: Partial } -const upcomingContest = { - ...contest, - startTime: now.add(1, 'day').toDate(), - endTime: now.add(2, 'day').toDate() -} - -const contestDetail = { - id: contest.id, - group: contest.group, - title: contest.title, - description: contest.description, - startTime: contest.startTime, - endTime: contest.endTime, - // eslint-disable-next-line @typescript-eslint/naming-convention - _count: { - contestRecord: 1 - } -} const ongoingContests = [ { @@ -71,46 +51,9 @@ const ongoingContests = [ title: contest.title, startTime: now.add(-1, 'day').toDate(), endTime: now.add(1, 'day').toDate(), - // eslint-disable-next-line @typescript-eslint/naming-convention - _count: { - contestRecord: 1 - } - } -] satisfies Partial[] -const ongoingContestsWithParticipants = [ - { - id: contest.id, - group: contest.group, - title: contest.title, - startTime: now.add(-1, 'day').toDate(), - endTime: now.add(1, 'day').toDate(), - participants: 1 - } -] - -const finishedContests = [ - { - id: contest.id + 1, - group: contest.group, - title: contest.title, - startTime: now.add(-2, 'day').toDate(), - endTime: now.add(-1, 'day').toDate(), - // eslint-disable-next-line @typescript-eslint/naming-convention - _count: { - contestRecord: 1 - } - } -] satisfies Partial[] -const finishedContestsWithParticipants = [ - { - id: contest.id + 1, - group: contest.group, - title: contest.title, - startTime: now.add(-2, 'day').toDate(), - endTime: now.add(-1, 'day').toDate(), participants: 1 } -] +] satisfies Partial[] const upcomingContests = [ { @@ -119,365 +62,339 @@ const upcomingContests = [ title: contest.title, startTime: now.add(1, 'day').toDate(), endTime: now.add(2, 'day').toDate(), - // eslint-disable-next-line @typescript-eslint/naming-convention - _count: { - contestRecord: 1 - } - } -] satisfies Partial[] -const upcomingContestsWithParticipants = [ - { - id: contest.id + 6, - group: contest.group, - title: contest.title, - startTime: now.add(1, 'day').toDate(), - endTime: now.add(2, 'day').toDate(), - participants: 1 - } -] - -const registeredOngoingContestsWithParticipants = [ - { - id: contest.id, - group: contest.group, - title: contest.title, - startTime: now.add(-1, 'day').toDate(), - endTime: now.add(1, 'day').toDate(), participants: 1 } -] +] satisfies Partial[] -const registeredUpcomingContestsWithParticipants = [ +const finishedContests = [ { - id: contest.id + 6, + id: contest.id + 1, group: contest.group, title: contest.title, - startTime: now.add(1, 'day').toDate(), - endTime: now.add(2, 'day').toDate(), + startTime: now.add(-2, 'day').toDate(), + endTime: now.add(-1, 'day').toDate(), participants: 1 } -] +] satisfies Partial[] const contests = [ ...ongoingContests, ...finishedContests, ...upcomingContests -] satisfies Partial[] - -const ongoingContest = ongoingContests[0] - -const earlierContest: Contest = { - ...contest, - id: contestId, - startTime: new Date('2999-12-01T11:00:00.000+09:00'), - endTime: new Date('2999-12-01T15:00:00.000+09:00'), - config: { - isVisible: false, - isRankisVisible: true - } -} - -const laterContest: Contest = { - ...contest, - id: contestId, - startTime: new Date('2999-12-01T12:00:00.000+09:00'), - endTime: new Date('2999-12-01T15:00:00.000+09:00'), - config: { - isVisible: false, - isRankisVisible: true - } -} - -const record: ContestRecord = { - id: 1, - contestId, - userId, - acceptedProblemNum: 0, - score: 0, - totalPenalty: 0, - createTime: new Date(), - updateTime: new Date() -} -const sortedContestRecordsWithUserDetail = [ - { - user: { - id: 13, - username: 'user10' - }, - score: 36, - totalPenalty: 720 - }, - { - user: { - id: 12, - username: 'user09' - }, - score: 33, - totalPenalty: 660 - }, - { - user: { - id: 11, - username: 'user08' - }, - score: 30, - totalPenalty: 600 - } -] - -const mockPrismaService = { - contest: { - findUnique: stub(), - findUniqueOrThrow: stub(), - findFirst: stub(), - findFirstOrThrow: stub(), - findMany: stub() - }, - contestRecord: { - findFirst: stub(), - findFirstOrThrow: stub(), - findMany: stub(), - create: stub(), - delete: stub() - }, - userGroup: { - findFirst: stub(), - findMany: stub() - }, - getPaginator: PrismaService.prototype.getPaginator -} +] satisfies Partial[] describe('ContestService', () => { let service: ContestService + let prisma: PrismaService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ - ContestService, - { provide: PrismaService, useValue: mockPrismaService } - ] + providers: [ContestService, PrismaService, ConfigService] }).compile() service = module.get(ContestService) + prisma = module.get(PrismaService) }) it('should be defined', () => { expect(service).to.be.ok }) - describe('getContests', () => { - beforeEach(() => { - mockPrismaService.contest.findMany.resolves(contests) - mockPrismaService.contestRecord.findMany.resolves([record]) + describe('getContestsByGroupId', () => { + it('should return ongoing, upcoming contests when userId is undefined', async () => { + const contests = await service.getContestsByGroupId(groupId) + expect(contests.ongoing).to.have.lengthOf(4) + expect(contests.upcoming).to.have.lengthOf(2) }) - afterEach(() => { - mockPrismaService.contest.findMany.reset() + + it('a contest should contain following fields when userId is undefined', async () => { + const contests = await service.getContestsByGroupId(groupId) + expect(contests.ongoing[0]).to.have.property('title') + expect(contests.ongoing[0]).to.have.property('startTime') + expect(contests.ongoing[0]).to.have.property('endTime') + expect(contests.ongoing[0]).to.have.property('participants') + expect(contests.ongoing[0].group).to.have.property('id') + expect(contests.ongoing[0].group).to.have.property('groupName') + expect(contests.upcoming[0]).to.have.property('title') + expect(contests.upcoming[0]).to.have.property('startTime') + expect(contests.upcoming[0]).to.have.property('endTime') + expect(contests.upcoming[0]).to.have.property('participants') + expect(contests.upcoming[0].group).to.have.property('id') + expect(contests.upcoming[0].group).to.have.property('groupName') }) - it('should return ongoing, upcoming contests when userId is undefined', async () => { - expect( - await service.getContestsByGroupId(undefinedUserId, groupId) - ).to.deep.equal({ - ongoing: ongoingContestsWithParticipants, - upcoming: upcomingContestsWithParticipants - }) + + it('should return ongoing, upcoming, registered ongoing, registered upcoming contests when userId is provided', async () => { + const contests = await service.getContestsByGroupId(groupId, user01Id) + expect(contests.ongoing).to.have.lengthOf(2) + expect(contests.upcoming).to.have.lengthOf(1) + expect(contests.registeredOngoing).to.have.lengthOf(2) + expect(contests.registeredUpcoming).to.have.lengthOf(2) }) - it('should return registered ongoing, registered upcoming, ongoing, upcoming contests', async () => { - expect(await service.getContestsByGroupId(userId, groupId)).to.deep.equal( - { - registeredOngoing: registeredOngoingContestsWithParticipants, - registeredUpcoming: registeredUpcomingContestsWithParticipants, - ongoing: ongoingContestsWithParticipants, - upcoming: upcomingContestsWithParticipants - } - ) + it('a contest should contain following fields when userId is provided', async () => { + const contests = await service.getContestsByGroupId(groupId, user01Id) + expect(contests.ongoing[0]).to.have.property('title') + expect(contests.ongoing[0]).to.have.property('startTime') + expect(contests.ongoing[0]).to.have.property('endTime') + expect(contests.ongoing[0]).to.have.property('participants') + expect(contests.ongoing[0].group).to.have.property('id') + expect(contests.ongoing[0].group).to.have.property('groupName') + expect(contests.upcoming[0]).to.have.property('title') + expect(contests.upcoming[0]).to.have.property('startTime') + expect(contests.upcoming[0]).to.have.property('endTime') + expect(contests.upcoming[0]).to.have.property('participants') + expect(contests.upcoming[0].group).to.have.property('id') + expect(contests.upcoming[0].group).to.have.property('groupName') + expect(contests.registeredOngoing[0]).to.have.property('title') + expect(contests.registeredOngoing[0]).to.have.property('startTime') + expect(contests.registeredOngoing[0]).to.have.property('endTime') + expect(contests.registeredOngoing[0]).to.have.property('participants') + expect(contests.registeredOngoing[0].group).to.have.property('id') + expect(contests.registeredOngoing[0].group).to.have.property('groupName') + expect(contests.registeredUpcoming[0]).to.have.property('title') + expect(contests.registeredUpcoming[0]).to.have.property('startTime') + expect(contests.registeredUpcoming[0]).to.have.property('endTime') + expect(contests.registeredUpcoming[0]).to.have.property('participants') + expect(contests.registeredUpcoming[0].group).to.have.property('id') + expect(contests.registeredUpcoming[0].group).to.have.property('groupName') }) }) - describe('getFinishedContests', () => { - after(() => { - mockPrismaService.contest.findMany.reset() + describe('getRegisteredOngoingUpcomingContests', () => { + it('should return registeredOngoing, registeredUpcoming contests', async () => { + const contests = await service.getRegisteredOngoingUpcomingContests( + groupId, + user01Id + ) + expect(contests.registeredOngoing).to.have.lengthOf(2) + expect(contests.registeredUpcoming).to.have.lengthOf(2) }) - it('should return finished contests when cursor is 0', async () => { - mockPrismaService.contest.findMany.resolves(finishedContests) - expect(await service.getFinishedContestsByGroupId(null, 1)).to.deep.equal( - { - finished: finishedContestsWithParticipants - } + + it('a contest should contain following fields', async () => { + const contests = await service.getRegisteredOngoingUpcomingContests( + groupId, + user01Id ) + expect(contests.registeredOngoing[0]).to.have.property('title') + expect(contests.registeredOngoing[0]).to.have.property('startTime') + expect(contests.registeredOngoing[0]).to.have.property('endTime') + expect(contests.registeredOngoing[0]).to.have.property('participants') + expect(contests.registeredOngoing[0].group).to.have.property('id') + expect(contests.registeredOngoing[0].group).to.have.property('groupName') + expect(contests.registeredUpcoming[0]).to.have.property('title') + expect(contests.registeredUpcoming[0]).to.have.property('startTime') + expect(contests.registeredUpcoming[0]).to.have.property('endTime') + expect(contests.registeredUpcoming[0]).to.have.property('participants') + expect(contests.registeredUpcoming[0].group).to.have.property('id') + expect(contests.registeredUpcoming[0].group).to.have.property('groupName') }) - }) - describe('startTimeCompare', () => { - it('should return -1 when a is earlier than b', async () => { + it("shold return contests whose title contains '신입생'", async () => { + const keyword = '신입생' + const contests = await service.getRegisteredOngoingUpcomingContests( + groupId, + user01Id, + keyword + ) expect( - service.startTimeCompare(earlierContest, laterContest) - ).to.deep.equal(-1) + contests.registeredOngoing.map((contest) => contest.title) + ).to.deep.equals(['24년도 소프트웨어학과 신입생 입학 테스트2']) }) + }) - it('should return 1 when b is earlier than a', async () => { - expect( - service.startTimeCompare(laterContest, earlierContest) - ).to.deep.equal(1) + describe('getRegisteredContestIds', async () => { + it("should return an array of contest's id user01 registered", async () => { + const contestIds = await service.getRegisteredContestIds(user01Id) + const registeredContestIds = [1, 3, 5, 7, 9, 11, 13, 15, 17] + contestIds.sort((a, b) => a - b) + expect(contestIds).to.deep.equal(registeredContestIds) }) + }) - it('should return 0 when a.startTime is equal b.startTime', async () => { - expect( - service.startTimeCompare(earlierContest, earlierContest) - ).to.deep.equal(0) + describe('getRegisteredFinishedContests', async () => { + it('should return only 2 contests that user01 registered but finished', async () => { + const takeNum = 4 + const contests = await service.getRegisteredFinishedContests( + null, + takeNum, + groupId, + user01Id + ) + expect(contests).to.have.lengthOf(takeNum) + }) + + it('should return a contest array which starts with id 9', async () => { + const takeNum = 2 + const prevCursor = 11 + const contests = await service.getRegisteredFinishedContests( + prevCursor, + takeNum, + groupId, + user01Id + ) + expect(contests[0].id).to.equals(9) + }) + + it('a contest should contain following fields', async () => { + const contests = await service.getRegisteredFinishedContests( + null, + 10, + 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') + }) + + it("shold return contests whose title contains '낮'", async () => { + const keyword = '낮' + const contests = await service.getRegisteredFinishedContests( + null, + 10, + groupId, + user01Id, + keyword + ) + expect(contests.map((contest) => contest.title)).to.deep.equals([ + '소프트의 낮' + ]) + }) + }) + + describe('getFinishedContestsByGroupId', () => { + it('should return finished contests', async () => { + const contests = await service.getFinishedContestsByGroupId( + null, + 10, + groupId + ) + const contestIds = contests.finished + .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) }) }) describe('filterOngoing', () => { - it('should return ongoing contests of the group', async () => { + it('should return ongoing contests of the group', () => { expect(service.filterOngoing(contests)).to.deep.equal(ongoingContests) }) }) describe('filterUpcoming', () => { - it('should return upcoming contests of the group', async () => { + it('should return upcoming contests of the group', () => { expect(service.filterUpcoming(contests)).to.deep.equal(upcomingContests) }) }) - describe('getContestsByGroupId', () => { - it('should return ongoing, upcoming, finished contests', async () => { - mockPrismaService.contest.findMany.resolves(contests) - expect( - await service.getContestsByGroupId(undefinedUserId, groupId) - ).to.deep.equal({ - ongoing: ongoingContestsWithParticipants, - upcoming: upcomingContestsWithParticipants - }) - mockPrismaService.contest.findMany.reset() - }) - - //TODO: test when userId is given - }) - describe('getContest', () => { it('should throw error when contest does not exist', async () => { - mockPrismaService.contest.findUniqueOrThrow.rejects( - new Prisma.PrismaClientKnownRequestError('contest', { - code: 'P2025', - clientVersion: '5.8.1' - }) - ) - await expect( - service.getContest(contestId + 999, groupId, 4) + service.getContest(999, groupId, user01Id) ).to.be.rejectedWith(EntityNotExistException) }) it('should return contest', async () => { - mockPrismaService.contest.findUniqueOrThrow.resolves(contestDetail) - mockPrismaService.contestRecord.findMany.resolves( - sortedContestRecordsWithUserDetail - ) - - expect(await service.getContest(groupId, contestId, 4)).to.deep.equal({ - ...contestDetail, - standings: sortedContestRecordsWithUserDetail.map((record, index) => ({ - ...record, - standing: index + 1 - })), - canRegister: true - }) + expect(await service.getContest(contestId, groupId, user01Id)).to.be.ok }) }) describe('createContestRecord', () => { - beforeEach(() => { - mockPrismaService.contest.findUniqueOrThrow.resolves(ongoingContest) - mockPrismaService.contestRecord.findFirst.resolves(null) - }) - afterEach(() => { - mockPrismaService.contest.findUniqueOrThrow.resolves(contest) - mockPrismaService.contestRecord.findFirst.resolves(null) + let contestRecordId = -1 + + after(async () => { + await prisma.contestRecord.delete({ + where: { + id: contestRecordId + } + }) }) it('should throw error when the contest does not exist', async () => { - mockPrismaService.contest.findUniqueOrThrow.rejects( - new Prisma.PrismaClientKnownRequestError('contest', { - code: 'P2002', - clientVersion: '5.1.1' - }) - ) await expect( - service.createContestRecord(contestId, userId) + service.createContestRecord(999, user01Id) ).to.be.rejectedWith(Prisma.PrismaClientKnownRequestError) }) it('should throw error when user is participated in contest again', async () => { - mockPrismaService.contestRecord.findFirst.resolves(record) await expect( - service.createContestRecord(contestId, userId) + service.createContestRecord(contestId, user01Id) ).to.be.rejectedWith(ConflictFoundException) }) it('should throw error when contest is not ongoing', async () => { - mockPrismaService.contest.findUniqueOrThrow.resolves(finishedContests[0]) - await expect( - service.createContestRecord(contestId, userId) - ).to.be.rejectedWith(ConflictFoundException) + await expect(service.createContestRecord(8, user01Id)).to.be.rejectedWith( + ConflictFoundException + ) }) - it('should successfully create contestRankACM', async () => { - mockPrismaService.contestRecord.create.reset() - await service.createContestRecord(contestId, userId) - expect(mockPrismaService.contestRecord.create.calledOnce).to.be.true + it('should register to a contest successfully', async () => { + const contestRecord = await service.createContestRecord(2, user01Id) + contestRecordId = contestRecord.id + expect( + await prisma.contestRecord.findUnique({ + where: { id: contestRecordId } + }) + ).to.deep.equals(contestRecord) }) }) + describe('deleteContestRecord', () => { + let contestRecord: ContestRecord | { id: number } = { id: -1 } + + afterEach(async () => { + try { + await prisma.contestRecord.delete({ + where: { id: contestRecord.id } + }) + } catch (error) { + if ( + !( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) + ) { + throw error + } + } + }) + it('should return deleted contest record', async () => { - mockPrismaService.contest.findUniqueOrThrow.resolves(upcomingContest) - mockPrismaService.contestRecord.findFirstOrThrow.resolves(record) - mockPrismaService.contestRecord.delete.resolves(record) + const newlyRegisteringContestId = 16 + contestRecord = await prisma.contestRecord.create({ + data: { + contestId: newlyRegisteringContestId, + userId: user01Id, + acceptedProblemNum: 0, + score: 0, + totalPenalty: 0 + } + }) + expect( - await service.deleteContestRecord(contestId, userId) - ).to.deep.equal(record) + await service.deleteContestRecord(newlyRegisteringContestId, user01Id) + ).to.deep.equal(contestRecord) }) it('should throw error when contest does not exist', async () => { - mockPrismaService.contest.findUniqueOrThrow.rejects( - new PrismaClientKnownRequestError('contest', { - code: 'P2025', - clientVersion: '5.8.1' - }) - ) await expect( - service.deleteContestRecord(contestId, userId) + service.deleteContestRecord(999, user01Id) ).to.be.rejectedWith(EntityNotExistException) }) + it('should throw error when contest record does not exist', async () => { - mockPrismaService.contestRecord.findFirstOrThrow.rejects( - new PrismaClientKnownRequestError('contestRecord', { - code: 'P2025', - clientVersion: '5.8.1' - }) - ) await expect( - service.deleteContestRecord(contestId, userId) + service.deleteContestRecord(16, user01Id) ).to.be.rejectedWith(EntityNotExistException) }) + it('should throw error when contest is ongoing', async () => { - mockPrismaService.contest.findUniqueOrThrow.resolves(ongoingContest) - mockPrismaService.contestRecord.findFirstOrThrow.resolves(record) await expect( - service.deleteContestRecord(contestId, userId) + service.deleteContestRecord(contestId, user01Id) ).to.be.rejectedWith(ForbiddenAccessException) }) - - it('should throw error when there is no record to delete', async () => { - mockPrismaService.contest.findUniqueOrThrow.resolves(upcomingContest) - mockPrismaService.contestRecord.findFirstOrThrow.resolves(record) - mockPrismaService.contestRecord.delete.rejects( - new PrismaClientKnownRequestError('contestRecord', { - code: 'P2025', - clientVersion: '5.8.1' - }) - ) - await expect( - service.deleteContestRecord(contestId, userId) - ).to.be.rejectedWith(EntityNotExistException) - }) }) }) diff --git a/backend/apps/client/src/contest/contest.service.ts b/backend/apps/client/src/contest/contest.service.ts index f7a3cf1e8f..9d80ed4f75 100644 --- a/backend/apps/client/src/contest/contest.service.ts +++ b/backend/apps/client/src/contest/contest.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common' -import { type Contest, Prisma } from '@prisma/client' +import { Prisma } from '@prisma/client' import { OPEN_SPACE_ID } from '@libs/constants' import { ConflictFoundException, @@ -26,31 +26,31 @@ export type ContestSelectResult = Prisma.ContestGetPayload<{ select: typeof contestSelectOption }> +export type ContestResult = Omit & { + participants: number +} + @Injectable() export class ContestService { constructor(private readonly prisma: PrismaService) {} - async getContestsByGroupId( - userId?: T, - groupId?: number + async getContestsByGroupId( + groupId: number, + userId?: T ): Promise< - T extends undefined + T extends undefined | null ? { - ongoing: Partial[] - upcoming: Partial[] + ongoing: ContestResult[] + upcoming: ContestResult[] } : { - registeredOngoing: Partial[] - registeredUpcoming: Partial[] - ongoing: Partial[] - upcoming: Partial[] + registeredOngoing: ContestResult[] + registeredUpcoming: ContestResult[] + ongoing: ContestResult[] + upcoming: ContestResult[] } > - - async getContestsByGroupId( - userId: number | null = null, - groupId = OPEN_SPACE_ID - ) { + async getContestsByGroupId(groupId: number, userId: number | null = null) { const now = new Date() if (userId == null) { const contests = await this.prisma.contest.findMany({ @@ -70,7 +70,8 @@ export class ContestService { } }) - const contestsWithParticipants = this.renameToParticipants(contests) + const contestsWithParticipants: ContestResult[] = + this.renameToParticipants(contests) return { ongoing: this.filterOngoing(contestsWithParticipants), @@ -78,18 +79,7 @@ export class ContestService { } } - const registeredContestRecords = await this.prisma.contestRecord.findMany({ - where: { - userId - }, - select: { - contestId: true - } - }) - - const registeredContestIds = registeredContestRecords.map( - (obj) => obj.contestId - ) + const registeredContestIds = await this.getRegisteredContestIds(userId) let registeredContests: ContestSelectResult[] = [] let restContests: ContestSelectResult[] = [] @@ -115,15 +105,15 @@ export class ContestService { restContests = await this.prisma.contest.findMany({ where: { groupId, - endTime: { - gt: now - }, config: { path: ['isVisible'], equals: true }, id: { notIn: registeredContestIds + }, + endTime: { + gt: now } }, select: contestSelectOption, @@ -146,10 +136,98 @@ export class ContestService { } } + async getRegisteredOngoingUpcomingContests( + groupId: number, + userId: number, + search?: string + ) { + const now = new Date() + const registeredContestIds = await this.getRegisteredContestIds(userId) + + const ongoingAndUpcomings = await this.prisma.contest.findMany({ + where: { + groupId, + id: { + in: registeredContestIds + }, + endTime: { + gt: now + }, + title: { + contains: search + } + }, + select: contestSelectOption + }) + + const ongoingAndUpcomingsWithParticipants = + this.renameToParticipants(ongoingAndUpcomings) + + return { + registeredOngoing: this.filterOngoing( + ongoingAndUpcomingsWithParticipants + ), + registeredUpcoming: this.filterUpcoming( + ongoingAndUpcomingsWithParticipants + ) + } + } + + async getRegisteredContestIds(userId: number) { + const registeredContestRecords = await this.prisma.contestRecord.findMany({ + where: { + userId + }, + select: { + contestId: true + } + }) + + return registeredContestRecords.map((obj) => obj.contestId) + } + + async getRegisteredFinishedContests( + cursor: number | null, + take: number, + groupId: number, + userId: number, + search?: string + ) { + const now = new Date() + const paginator = this.prisma.getPaginator(cursor) + + const registeredContestIds = await this.getRegisteredContestIds(userId) + const contests = await this.prisma.contest.findMany({ + ...paginator, + take, + where: { + groupId, + endTime: { + lte: now + }, + id: { + in: registeredContestIds + }, + title: { + contains: search + }, + config: { + path: ['isVisible'], + equals: true + } + }, + select: contestSelectOption, + orderBy: [{ endTime: 'desc' }, { id: 'desc' }] + }) + + return this.renameToParticipants(contests) + } + async getFinishedContestsByGroupId( cursor: number | null, take: number, - groupId = OPEN_SPACE_ID + groupId: number, + search?: string ) { const paginator = this.prisma.getPaginator(cursor) const now = new Date() @@ -165,12 +243,13 @@ export class ContestService { config: { path: ['isVisible'], equals: true + }, + title: { + contains: search } }, select: contestSelectOption, - orderBy: { - endTime: 'desc' - } + orderBy: [{ endTime: 'desc' }, { id: 'desc' }] }) return { finished: this.renameToParticipants(finished) } } @@ -184,37 +263,19 @@ export class ContestService { })) } - startTimeCompare( - a: Partial & Pick, - b: Partial & Pick - ) { - if (a.startTime < b.startTime) { - return -1 - } - if (a.startTime > b.startTime) { - return 1 - } - return 0 - } - - filterOngoing( - contests: Array & Pick> - ) { + filterOngoing(contests: ContestResult[]) { const now = new Date() - const ongoingContest = contests.filter( - (contest) => contest.startTime <= now && contest.endTime > now - ) + const ongoingContest = contests + .filter((contest) => contest.startTime <= now && contest.endTime > now) + .sort((a, b) => a.endTime.getTime() - b.endTime.getTime()) return ongoingContest } - filterUpcoming( - contests: Array & Pick> - ) { + filterUpcoming(contests: ContestResult[]) { const now = new Date() - const upcomingContest = contests.filter( - (contest) => contest.startTime > now - ) - upcomingContest.sort(this.startTimeCompare) + const upcomingContest = contests + .filter((contest) => contest.startTime > now) + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()) return upcomingContest } diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index bc2d3fc970..70c3f8f005 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -30,6 +30,7 @@ let publicGroup: Group let privateGroup: Group const problems: Problem[] = [] const problemTestcases: ProblemTestcase[] = [] +const contests: Contest[] = [] const endedContests: Contest[] = [] const ongoingContests: Contest[] = [] const upcomingContests: Contest[] = [] @@ -1179,6 +1180,7 @@ const createContests = async () => { const now = new Date() for (const obj of contestData) { const contest = await prisma.contest.create(obj) + contests.push(contest) if (now < obj.data.startTime) { upcomingContests.push(contest) } else if (obj.data.endTime < now) { @@ -1607,21 +1609,23 @@ const createContestRecords = async () => { contestRecords.push(contestRecord) i++ } - // User 1이 Future Contest에 참가한 record를 추가합니다. - // 그래야 upcoming contest에 참가한 User 1의 contest register를 un-register할 수 있습니다. - contestRecords.push( - await prisma.contestRecord.create({ - data: { - //User1 - userId: 4, - //Future Contest - contestId: 15, - acceptedProblemNum: 0, - score: 0, - totalPenalty: 0 - } - }) - ) + + // upcoming contest에 참가한 User 1의 contest register를 un-register하는 기능과, + // registered upcoming, ongoing, finished contest를 조회하는 기능을 확인하기 위함 + const user01Id = 4 + for (let contestId = 3; contestId <= contests.length; contestId += 2) { + contestRecords.push( + await prisma.contestRecord.create({ + data: { + userId: user01Id, + contestId, + acceptedProblemNum: 0, + score: 0, + totalPenalty: 0 + } + }) + ) + } return contestRecords } diff --git a/collection/client/Contest/Get finished contests/Succeed.bru b/collection/client/Contest/Get finished contests/Succeed.bru index 88e29a413c..3c5ffcb9d3 100644 --- a/collection/client/Contest/Get finished contests/Succeed.bru +++ b/collection/client/Contest/Get finished contests/Succeed.bru @@ -14,6 +14,7 @@ query { ~take: 10 ~cursor: 1 ~groupId: 1 + ~search: 소 } assert { @@ -24,19 +25,23 @@ assert { res("finished")[0].endTime: isString res("finished")[0].group.id: isNumber res("finished")[0].group.groupName: isString + res("finished")[0].participants: isNumber } docs { - # Add Finished Contests + ## Add Finished Contests 종료된 대회들을 가져옵니다. - ## Query + pagination이 가능하며, 제목 검색 기능을 포함합니다. + + ### Query | 이름 | 타입 | 설명 | |-----|-----|-----| |take |Integer|가져올 대회 개수 (default: 10)| |cursor|Integer|cursor 값 다음의 ID를 가진 대회들을 반환| |groupId |Integer|대회가 속한 Group ID (default: 1)| + |search|String|title을 기준으로 검색할 키워드. 포함하지 않으면 검색을 수행하지 않음| } diff --git a/collection/client/Contest/Get finished contests/[403] Not a Member of the Group.bru b/collection/client/Contest/Get finished contests/[403] Not a Member of the Group.bru deleted file mode 100644 index 753ad57211..0000000000 --- a/collection/client/Contest/Get finished contests/[403] Not a Member of the Group.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: [403] Not a user of the group - type: http - seq: 2 -} - -get { - url: {{baseUrl}}/contest/finished?groupId=99999 - body: none - auth: none -} - -query { - groupId: 99999 -} - -assert { - res.status: eq 403 -} diff --git a/collection/client/Contest/Get ongoing & upcoming contests (logged in)/Succeed.bru b/collection/client/Contest/Get ongoing & upcoming contests (logged in)/Succeed.bru index f06bbe9186..e9a09f5424 100644 --- a/collection/client/Contest/Get ongoing & upcoming contests (logged in)/Succeed.bru +++ b/collection/client/Contest/Get ongoing & upcoming contests (logged in)/Succeed.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{baseUrl}}/contest/auth + url: {{baseUrl}}/contest/ongoing-upcoming-with-registered body: none auth: none } @@ -18,12 +18,25 @@ assert { res("ongoing")[0].group.id: isNumber res("ongoing")[0].group.groupName: isString res.status: eq 200 - res("ongoing")[0].participants: isNumber + res("ongoing")[0].participants: isNumber + res("registeredOngoing"): isDefined + res("registeredUpcoming"): isDefined +} + +script:pre-request { + await require("./login").loginUser(req); } docs { - # Get Ongoing & Upcoming contests (logged in) - --- - - 종료되지 않은 대회 중 현재 로그인한 유저가 등록한 대회와 등록하지 않은 대회를 구분하여 반환합니다. - - 유저가 등록한 대회 정보는 `registeredOngoing`, `registeredUpcoming` 으로, 나머지는 `ongoing`, `upcoming` 으로 분류되어 반환됩니다. + ## Get Ongoing & Upcoming contests (logged in) + + 종료되지 않은 대회 중 현재 로그인한 유저가 등록한 대회와 등록하지 않은 대회를 구분하여 반환합니다. 고로 로그인이 필요합니다. + + 유저가 등록한 대회 정보는 `registeredOngoing`, `registeredUpcoming` 으로, 나머지는 `ongoing`, `upcoming` 으로 분류되어 반환됩니다. + + ### Query + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |groupId |Integer|대회가 속한 Group ID (default: 1)| } diff --git a/collection/client/Contest/Get ongoing & upcoming contests (logged in)/[401] Unauthorized.bru b/collection/client/Contest/Get ongoing & upcoming contests (logged in)/[401] Unauthorized.bru deleted file mode 100644 index 2197fedb4d..0000000000 --- a/collection/client/Contest/Get ongoing & upcoming contests (logged in)/[401] Unauthorized.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: [401] Unauthorized - type: http - seq: 2 -} - -get { - url: {{baseUrl}}/contest/auth - body: none - auth: none -} - -headers { - Authorization: Unauthorized!! -} - -assert { - res.status: eq 401 -} diff --git a/collection/client/Contest/Get ongoing & upcoming contests/Succeed.bru b/collection/client/Contest/Get ongoing & upcoming contests/Succeed.bru index e81fb67761..900493898c 100644 --- a/collection/client/Contest/Get ongoing & upcoming contests/Succeed.bru +++ b/collection/client/Contest/Get ongoing & upcoming contests/Succeed.bru @@ -5,13 +5,13 @@ meta { } get { - url: {{baseUrl}}/contest + url: {{baseUrl}}/contest/ongoing-upcoming body: none auth: none } query { - ~groupId: 2 + ~groupId: 1 } assert { @@ -23,16 +23,19 @@ assert { res("ongoing")[0].group.id: isNumber res("ongoing")[0].group.groupName: isString res("ongoing")[0].participants: isNumber + res("ongoing"): isDefined + res("upcoming"): isDefined } docs { - # Get Ongoing & Upcoming Contests + ## Get Ongoing & Upcoming Contests - 아직 종료되지 않은 대회들을 가져옵니다. + 아직 종료되지 않은 대회들을 가져옵니다. 로그인은 필요하지 않으며, groupId에 해당하는 ongoing 대회와 upcoming 대회를 배열로 가져옵니다. pagination은 수행하지 않습니다. - ## Query + ### Query | 이름 | 타입 | 설명 | |-----|-----|-----| |groupId |Integer|대회가 속한 Group ID (default: 1)| + } diff --git a/collection/client/Contest/Get ongoing & upcoming contests/[403] Not a Member of the Group.bru b/collection/client/Contest/Get ongoing & upcoming contests/[403] Not a Member of the Group.bru deleted file mode 100644 index 4dfee6dcc4..0000000000 --- a/collection/client/Contest/Get ongoing & upcoming contests/[403] Not a Member of the Group.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: [403] Not a user of the group - type: http - seq: 2 -} - -get { - url: {{baseUrl}}/contest?groupId=9999 - body: none - auth: none -} - -query { - groupId: 9999 -} - -assert { - res.status: eq 403 -} diff --git a/collection/client/Contest/Get registered finished contests/Succeed.bru b/collection/client/Contest/Get registered finished contests/Succeed.bru new file mode 100644 index 0000000000..8e74a96888 --- /dev/null +++ b/collection/client/Contest/Get registered finished contests/Succeed.bru @@ -0,0 +1,50 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/contest/registered-finished + body: none + auth: none +} + +query { + ~groupId: 2 + ~cursor: 1 + ~take: 5 + ~search: 밤 +} + +assert { + res.status: eq 200 + res.body[0].id: isNumber + res.body[0].title: isString + res.body[0].startTime: isString + res.body[0].endTime: isString + res.body[0].group.id: isNumber + res.body[0].group.groupName: isString + res.body[0].participants: isNumber +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## Get Registered Finished Contests + + 사용자가 참가 신청을 한 종료된 대회들을 가져옵니다. + + pagination과 제목 검색이 가능합니다. + + ### Query + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |take |Integer|가져올 대회 개수 (default: 10)| + |cursor|Integer|cursor 값 다음의 ID를 가진 대회들을 반환| + |groupId |Integer|대회가 속한 Group ID (default: 1)| + |search|String|title을 기준으로 검색할 키워드. 포함하지 않으면 검색을 수행하지 않음| +} diff --git a/collection/client/Contest/Get registered ongoing & upcoming contests/Succeed.bru b/collection/client/Contest/Get registered ongoing & upcoming contests/Succeed.bru new file mode 100644 index 0000000000..54b3eb52b3 --- /dev/null +++ b/collection/client/Contest/Get registered ongoing & upcoming contests/Succeed.bru @@ -0,0 +1,44 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/contest/registered-ongoing-upcoming + body: none + auth: none +} + +query { + ~groupId: 2 + ~search: hello +} + +assert { + res("registeredOngoing"): isDefined + res("registeredUpcoming"): isDefined +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## Get Registered Ongoing & Upcoming Contests + + 사용자가 참가 신청을 한 Ongoing, Upcoming 대회들을 가져옵니다. + + 로그인이 필요하며, pagination을 수행하지 않습니다. + + 제목 검색 기능을 수행합니다. + + Get ongoing&upcoming contests (logged in) API와 다른 점은 검색이 가능하다는 점입니다. + + ### Query + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |groupId |Integer|대회가 속한 Group ID (default: 1)| + |search|String|title을 기준으로 검색할 키워드. 포함하지 않으면 검색을 수행하지 않음| +} From eeb633dc89281637c7adc3d7f0f5dbcd7d3cb5a6 Mon Sep 17 00:00:00 2001 From: Jiho Park <59248080+jihorobert@users.noreply.github.com> Date: Wed, 14 Feb 2024 16:00:48 +0900 Subject: [PATCH 03/14] feat(fe): admin problem datatable toolbar (#1381) * feat(fe): add problem datatable toolbar * feat: use gql later * fix: add description for tags column --- .../app/admin/problem/_components/Columns.tsx | 36 +++- frontend-client/app/admin/problem/page.tsx | 80 +++++++- frontend-client/components/DataTableAdmin.tsx | 73 +++++++- .../components/DataTableColumnHeader.tsx | 2 +- .../components/DataTableFacetedFilter.tsx | 176 ++++++++++++++++++ frontend-client/components/TagsSelect.tsx | 4 +- 6 files changed, 351 insertions(+), 20 deletions(-) create mode 100644 frontend-client/components/DataTableFacetedFilter.tsx diff --git a/frontend-client/app/admin/problem/_components/Columns.tsx b/frontend-client/app/admin/problem/_components/Columns.tsx index 5f2f598cc3..577fd53b39 100644 --- a/frontend-client/app/admin/problem/_components/Columns.tsx +++ b/frontend-client/app/admin/problem/_components/Columns.tsx @@ -10,6 +10,11 @@ import { useState } from 'react' import { FiEyeOff } from 'react-icons/fi' import { FiEye } from 'react-icons/fi' +interface Tag { + id: number + name: string +} + // No api for hidden (임시 hiddenCell 만듦) function HiddenCell() { const [visible, setVisible] = useState(false) @@ -60,7 +65,7 @@ export const columns: ColumnDef[] = [ ), cell: ({ row }) => (
-
{row.getValue('title')}
+ {row.getValue('title')}
{row.original.tags?.map((tag) => ( @@ -79,10 +84,35 @@ export const columns: ColumnDef[] = [ enableSorting: false, enableHiding: false }, + /** + * @description + * mad this column for filtering tags + * doesn't show in datatable + */ + { + accessorKey: 'tags', + header: () => {}, + cell: () => {}, + + filterFn: (row, id, value) => { + const tags = row.original.tags + + if (!tags?.length) { + return false // 만약 태그가 없는 경우 false 반환 + } + + const tagValue = row.getValue(id) + const valueArray = value as number[] // value 배열로 타입 변환 + const tagIdArray = (tagValue as Tag[]).map((tag) => tag.id) // tagValue의 id로 이루어진 배열 생성 + const result = tagIdArray.every((tagId) => valueArray.includes(tagId)) // 모든 tagId가 valueArray에 포함되어 있는지 확인 + + return result + } + }, { accessorKey: 'date', header: ({ column }) => ( - + ), cell: ({ row }) => row.getValue('date') }, @@ -118,7 +148,7 @@ export const columns: ColumnDef[] = [ { accessorKey: 'hidden', header: ({ column }) => ( - + ), cell: HiddenCell } diff --git a/frontend-client/app/admin/problem/page.tsx b/frontend-client/app/admin/problem/page.tsx index b8fd9f89f7..4bcc1971d9 100644 --- a/frontend-client/app/admin/problem/page.tsx +++ b/frontend-client/app/admin/problem/page.tsx @@ -4,17 +4,87 @@ import type { Problem } from '@/types/type' import * as React from 'react' import { columns } from './_components/Columns' -export const dynamic = 'force-dynamic' +// const GET_PROBLEMS = gql` +// query GetProblems($groupId: Int!, $cursor: Int, $take: Int!, $input: FilterProblemsInput!) { +// getProblems(groupId: $groupId, cursor: $cursor, take: $take, input: $input) { +// id +// title +// difficulty +// submissionCount +// acceptedRate +// languages +// problemTag{ +// tag{ +// id +// name +// } +// } +// } +// } +// ` + +// interface DataTableProblem { +// id: string +// title: string +// difficulty: string +// submissionCount: number +// acceptedRate: number +// languages: string[] +// problemTag: Tag[] +// } -// 우선 Codedang Client API에서 데이터 가져옴 -> 나중에 Codedang Admin에서 가져오도록 수정 +// const GET_PROBLEMS = gql` +// query GetProblems($groupId: Int!, $cursor: Int, $take: Int!, $input: FilterProblemsInput!) { +// getProblems(groupId: $groupId, cursor: $cursor, take: $take, input: $input) { +// id +// title +// difficulty +// submissionCount +// acceptedRate +// languages +// problemTag{ +// tag{ +// id +// name +// } +// } +// } +// } +// ` +export const dynamic = 'force-dynamic' export default async function Page() { - // 현재 이부분에서 tags data를 가져오지 못함 + // const [problems, setProblems] = useState([]) + // useEffect(() => { + // fetcherGql(GET_PROBLEMS, { + // groupId: 1, + // cursor: 1, + // take: 10, + // input: { + // difficulty: ["Level1", "Level2", "Level3", "Level4", "Level5"], + // languages: ["C", "Cpp", "Java", "Python3"], + // } + + // }).then((data) => { + // const transformedData = data.getProblems.map( + // (problem: { id: string; title: string, difficulty: string, submissionCount: number, acceptedRate: number, languages: string[], problemTag: Tag[] }) => ({ + // ...problem, + // id: Number(problem.id), + // problemTag: problem.problemTag.map((tag: Tag) => ({ + // ...tag, + // id: Number(tag.id) + // })) + // }) + // ) + // setProblems(transformedData) + // }) + // }, []) + + // TODO: Codedang Admin Get Problems API에 problemTags내용이 추가되면 Admin에서 가져오기 (현재는 problemTag 전부 null로 돼있음) const { problems }: { problems: Problem[] } = await fetcher .get('problem', { searchParams: { - take: 10, - contestId: 1 + take: 15 } }) .json() diff --git a/frontend-client/components/DataTableAdmin.tsx b/frontend-client/components/DataTableAdmin.tsx index ac4f3b192b..4ae8117df7 100644 --- a/frontend-client/components/DataTableAdmin.tsx +++ b/frontend-client/components/DataTableAdmin.tsx @@ -1,5 +1,6 @@ 'use client' +import CheckboxSelect from '@/components/CheckboxSelect' import { Table, TableBody, @@ -8,6 +9,8 @@ import { TableHeader, TableRow } from '@/components/ui/table' +import { fetcherGql } from '@/lib/utils' +import { gql } from '@apollo/client' import type { ColumnDef, ColumnFiltersState, @@ -24,25 +27,54 @@ import { getSortedRowModel, useReactTable } from '@tanstack/react-table' -import React from 'react' +import React, { useEffect, useState } from 'react' +import { DataTableFacetedFilter } from './DataTableFacetedFilter' import { DataTablePagination } from './DataTablePagination' +import { Input } from './ui/input' + +interface Tag { + id: number + name: string +} interface DataTableProps { columns: ColumnDef[] data: TData[] } +const GET_TAGS = gql` + query GetTags { + getTags { + id + name + } + } +` + +// dummy data +const languageOptions = ['C', 'Cpp', 'Golang', 'Java', 'Python2', 'Python3'] + export function DataTableAdmin({ columns, data }: DataTableProps) { - const [rowSelection, setRowSelection] = React.useState({}) - const [columnVisibility, setColumnVisibility] = - React.useState({}) - const [columnFilters, setColumnFilters] = React.useState( - [] - ) - const [sorting, setSorting] = React.useState([]) + const [rowSelection, setRowSelection] = useState({}) + const [columnVisibility, setColumnVisibility] = useState({}) + const [columnFilters, setColumnFilters] = useState([]) + const [sorting, setSorting] = useState([]) + const [tags, setTags] = useState([]) + + useEffect(() => { + fetcherGql(GET_TAGS).then((data) => { + const transformedData = data.getTags.map( + (tag: { id: string; name: string }) => ({ + ...tag, + id: Number(tag.id) + }) + ) + setTags(transformedData) + }) + }, []) const table = useReactTable({ data, @@ -65,10 +97,33 @@ export function DataTableAdmin({ getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues() }) - return (
{/* */} +
+ + table.getColumn('title')?.setFilterValue(event.target.value) + } + className="h-10 w-[150px] lg:w-[250px]" + /> + {}} + /> + + {table.getColumn('tags') && ( + + )} +
+
diff --git a/frontend-client/components/DataTableColumnHeader.tsx b/frontend-client/components/DataTableColumnHeader.tsx index 3ec195216d..5bfb57e560 100644 --- a/frontend-client/components/DataTableColumnHeader.tsx +++ b/frontend-client/components/DataTableColumnHeader.tsx @@ -28,7 +28,7 @@ export function DataTableColumnHeader({ }: DataTableColumnHeaderProps) { // Title column if (!column.getCanSort()) { - return
{title}
+ return
{title}
} return ( diff --git a/frontend-client/components/DataTableFacetedFilter.tsx b/frontend-client/components/DataTableFacetedFilter.tsx new file mode 100644 index 0000000000..82cdb3e4d2 --- /dev/null +++ b/frontend-client/components/DataTableFacetedFilter.tsx @@ -0,0 +1,176 @@ +'use client' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover' +import { Separator } from '@/components/ui/separator' +import { PlusCircledIcon } from '@radix-ui/react-icons' +import { Column } from '@tanstack/react-table' +import * as React from 'react' +import { useState } from 'react' +import { IoClose } from 'react-icons/io5' + +interface Tag { + id: number + name: string +} + +interface DataTableFacetedFilterProps { + column?: Column + title?: string + options: Tag[] +} + +export function DataTableFacetedFilter({ + column, + title, + options +}: DataTableFacetedFilterProps) { + const selectedValues = new Set(column?.getFilterValue() as number[]) + const [inputValue, setInputValue] = useState('') + + return ( + + + + + + + + setInputValue(e)} + /> + +
+ {inputValue === '' + ? options.map((option) => ( + { + if (selectedValues.has(option.id)) { + selectedValues.delete(option.id) + } else { + selectedValues.add(option.id) + } + const filterValues = Array.from(selectedValues) + column?.setFilterValue( + filterValues.length ? filterValues : undefined + ) + }} + > + {option.name} + {selectedValues.has(option.id) ? ( + + ) : ( + '' + )} + + )) + : options + .filter((option) => + option.name + .toLowerCase() + .includes(inputValue.toLowerCase()) + ) + .map((option) => ( + { + if (selectedValues.has(option.id)) { + selectedValues.delete(option.id) + } else { + selectedValues.add(option.id) + } + const filterValues = Array.from(selectedValues) + column?.setFilterValue( + filterValues.length ? filterValues : undefined + ) + }} + > + {option.name} + {selectedValues.has(option.id) ? ( + + ) : ( + '' + )} + + ))} +
+ + {selectedValues.size > 0 && ( + <> + + + column?.setFilterValue(undefined)} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ ) +} diff --git a/frontend-client/components/TagsSelect.tsx b/frontend-client/components/TagsSelect.tsx index 658f52a72a..50abdb84fa 100644 --- a/frontend-client/components/TagsSelect.tsx +++ b/frontend-client/components/TagsSelect.tsx @@ -81,8 +81,8 @@ export default function TagsSelect({ options, onChange }: DataProps) { - - + + Date: Wed, 14 Feb 2024 16:05:15 +0900 Subject: [PATCH 04/14] fix(fe): add fallback style of text editor (#1374) * fix(fe): add fallback style to text editor * fix: remove left margin of admin sidebar * fix: invalid workbook problem type * fix: resolve type error --- frontend-client/app/(main)/_components/ProblemCard.tsx | 2 +- .../app/(main)/_components/ProblemCards.tsx | 4 ++-- frontend-client/app/admin/layout.tsx | 4 ++-- frontend-client/components/TextEditor.tsx | 10 ++++++---- frontend-client/types/type.ts | 4 +++- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/frontend-client/app/(main)/_components/ProblemCard.tsx b/frontend-client/app/(main)/_components/ProblemCard.tsx index ed936592f8..077f87e5b6 100644 --- a/frontend-client/app/(main)/_components/ProblemCard.tsx +++ b/frontend-client/app/(main)/_components/ProblemCard.tsx @@ -15,7 +15,7 @@ export default function ProblemCard({ problem }: Props) { type={problem.difficulty} >{`Level ${problem.difficulty[5]}`} - {`#${problem.problemId} ${problem.title}`} + {`#${problem.id} ${problem.title}`} diff --git a/frontend-client/app/(main)/_components/ProblemCards.tsx b/frontend-client/app/(main)/_components/ProblemCards.tsx index 2561015eb5..bb7b275d28 100644 --- a/frontend-client/app/(main)/_components/ProblemCards.tsx +++ b/frontend-client/app/(main)/_components/ProblemCards.tsx @@ -25,8 +25,8 @@ export default async function ProblemCards() { {problems.map((problem) => { return ( diff --git a/frontend-client/app/admin/layout.tsx b/frontend-client/app/admin/layout.tsx index 08c1cd7f2c..380a6bc47b 100644 --- a/frontend-client/app/admin/layout.tsx +++ b/frontend-client/app/admin/layout.tsx @@ -8,8 +8,8 @@ import SideBar from './_components/SideBar' export default function Layout({ children }: { children: React.ReactNode }) { return ( -
-
verifyCode() @@ -131,11 +168,19 @@ export default function SignUpEmailVerify() { {errors.verificationCode ? errors.verificationCode?.message : codeError}

{sentEmail && + !expired && !errors.verificationCode && codeError === '' && !emailVerified && (

We've sent an email!

)} + {expired && ( +

+ Verification code expired +
+ Please resend an email and try again +

+ )} {!sentEmail ? ( - ) : ( + ) : !expired ? ( + ) : ( + )} ) From 47784db6df01e703092f7340f24950ff1f350783 Mon Sep 17 00:00:00 2001 From: Sori Lim Date: Wed, 14 Feb 2024 17:08:51 +0900 Subject: [PATCH 06/14] feat(fe): change logo in editor page (#1384) --- frontend-client/app/problem/[id]/layout.tsx | 4 ++-- frontend-client/public/codedang-editor.svg | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 frontend-client/public/codedang-editor.svg diff --git a/frontend-client/app/problem/[id]/layout.tsx b/frontend-client/app/problem/[id]/layout.tsx index 3c28088e43..932a2c388d 100644 --- a/frontend-client/app/problem/[id]/layout.tsx +++ b/frontend-client/app/problem/[id]/layout.tsx @@ -1,5 +1,5 @@ -import icon from '@/app/favicon.ico' import { fetcher } from '@/lib/utils' +import codedangLogo from '@/public/codedang-editor.svg' import type { ProblemDetail } from '@/types/type' import Image from 'next/image' import Link from 'next/link' @@ -21,7 +21,7 @@ export default async function layout({
- +
Problem diff --git a/frontend-client/public/codedang-editor.svg b/frontend-client/public/codedang-editor.svg new file mode 100644 index 0000000000..7764a1a433 --- /dev/null +++ b/frontend-client/public/codedang-editor.svg @@ -0,0 +1,7 @@ + + + + + + + From f3433fb1cde7afc723ce065380487a988aa71912 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 Feb 2024 14:37:30 +0900 Subject: [PATCH 07/14] fix(deps): update tiptap monorepo to ^2.2.2 (#1356) * fix(deps): update tiptap monorepo to ^2.2.2 * fix: fix lockfile --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jaemin Choi <1dotolee@gmail.com> --- frontend-client/package.json | 10 +- frontend/package.json | 6 +- pnpm-lock.yaml | 260 +++++++++++++++++------------------ 3 files changed, 138 insertions(+), 138 deletions(-) diff --git a/frontend-client/package.json b/frontend-client/package.json index b9cb284fe6..2e0e2be4d4 100644 --- a/frontend-client/package.json +++ b/frontend-client/package.json @@ -34,11 +34,11 @@ "@radix-ui/react-toggle": "^1.0.3", "@tailwindcss/typography": "^0.5.10", "@tanstack/react-table": "^8.11.8", - "@tiptap/extension-link": "^2.2.1", - "@tiptap/extension-placeholder": "^2.2.1", - "@tiptap/pm": "^2.1.16", - "@tiptap/react": "^2.2.1", - "@tiptap/starter-kit": "^2.1.16", + "@tiptap/extension-link": "^2.2.2", + "@tiptap/extension-placeholder": "^2.2.2", + "@tiptap/pm": "^2.2.2", + "@tiptap/react": "^2.2.2", + "@tiptap/starter-kit": "^2.2.2", "@uiw/codemirror-extensions-langs": "^4.21.21", "@uiw/codemirror-themes": "^4.21.21", "@uiw/react-codemirror": "^4.21.21", diff --git a/frontend/package.json b/frontend/package.json index fe57108381..337dd0c0ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,9 +20,9 @@ "@codemirror/state": "^6.4.0", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.23.1", - "@tiptap/pm": "^2.2.1", - "@tiptap/starter-kit": "^2.2.1", - "@tiptap/vue-3": "^2.2.1", + "@tiptap/pm": "^2.2.2", + "@tiptap/starter-kit": "^2.2.2", + "@tiptap/vue-3": "^2.2.2", "@vee-validate/zod": "^4.12.5", "@vue/apollo-composable": "4.0.1", "@vueuse/components": "^10.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40e9d55c32..c3daaa857a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,14 +319,14 @@ importers: specifier: ^6.23.1 version: 6.23.1 '@tiptap/pm': - specifier: ^2.2.1 - version: 2.2.1 + specifier: ^2.2.2 + version: 2.2.2 '@tiptap/starter-kit': - specifier: ^2.2.1 - version: 2.2.1(@tiptap/pm@2.2.1) + specifier: ^2.2.2 + version: 2.2.2(@tiptap/pm@2.2.2) '@tiptap/vue-3': - specifier: ^2.2.1 - version: 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1)(vue@3.4.15) + specifier: ^2.2.2 + version: 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2)(vue@3.4.15) '@vee-validate/zod': specifier: ^4.12.5 version: 4.12.5(vue@3.4.15) @@ -554,20 +554,20 @@ importers: specifier: ^8.11.8 version: 8.11.8(react-dom@18.2.0)(react@18.2.0) '@tiptap/extension-link': - specifier: ^2.2.1 - version: 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) + specifier: ^2.2.2 + version: 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) '@tiptap/extension-placeholder': - specifier: ^2.2.1 - version: 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) + specifier: ^2.2.2 + version: 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) '@tiptap/pm': - specifier: ^2.1.16 - version: 2.2.1 + specifier: ^2.2.2 + version: 2.2.2 '@tiptap/react': - specifier: ^2.2.1 - version: 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1)(react-dom@18.2.0)(react@18.2.0) + specifier: ^2.2.2 + version: 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2)(react-dom@18.2.0)(react@18.2.0) '@tiptap/starter-kit': - specifier: ^2.1.16 - version: 2.2.1(@tiptap/pm@2.2.1) + specifier: ^2.2.2 + version: 2.2.2(@tiptap/pm@2.2.2) '@uiw/codemirror-extensions-langs': specifier: ^4.21.21 version: 4.21.21(@codemirror/autocomplete@6.12.0)(@codemirror/language-data@6.4.0)(@codemirror/language@6.10.1)(@codemirror/legacy-modes@6.3.3)(@codemirror/state@6.4.0)(@codemirror/view@6.23.1)(@lezer/common@1.2.1)(@lezer/highlight@1.2.0)(@lezer/javascript@1.4.13)(@lezer/lr@1.4.0) @@ -6226,213 +6226,213 @@ packages: resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==} dev: false - /@tiptap/core@2.2.1(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-9G6N2d5q4M5i/elK61qu28uO5MBDKEbStQnHuVKNe+LVNpb6xZal1AcIilmq1FVeuCUh3kYl3cFH3nBQOXtZUw==} + /@tiptap/core@2.2.2(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-fec26LtNgYFGhKzEA9+Of+qLKIKUxDL/XZQofoPcxP71NffcmpZ+ZjAx9NjnvuYtvylUSySZiPauY6WhN3aprw==} peerDependencies: '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/pm': 2.2.1 + '@tiptap/pm': 2.2.2 dev: false - /@tiptap/extension-blockquote@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-KfL7c28ueH8yKTkZiO9LlErAVjZyi5j+Kw42pu06aOrXWNH6TW8CSufR6UB2XZcFX6Sixi2qYpwxs1YNnItmcg==} + /@tiptap/extension-blockquote@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-ENCGx/yhNdUQ0epGOeTN4HFeUSfQDK2CQBy2szkQVtzG/Vhv8ExxBWTxHJcMoeSfEVmKag4B506vfRkKH24IMA==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-bold@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-MSFbbrUG0hErM9sUmJenGPRBjQV/HfcLf7kqpxduPoxA4r2XOsbUKGSbPEQ3vU3vMi9Obkknt0JLnS5WofIIGw==} + /@tiptap/extension-bold@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-8/KLpPHwO+GXlWsXEION7ppLfFIaSpnw5m2QYXz/LGRK32hzpTavbdXV3rx9+Vu+7Z+0yQF9G/ro1z9dqTQHpw==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-bubble-menu@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-FQO1CDS777E34T8jwnYDBz3VmGyVzv3Olj0o01c3fHR7OSnA7urAxx+z9Z4CWqUJhRMV9td0v0wiZjQ4q1DdYw==} + /@tiptap/extension-bubble-menu@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-W3OvoHxgBdQSrlX8FXvIs5wA+eHXe/0jGsqQdwLXPtqZOSR4Ks9OLmxDk2+O8ci0KCLPb6/doJYg7j/8Ic4KRg==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 tippy.js: 6.3.7 dev: false - /@tiptap/extension-bullet-list@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-MvMQRykJUdIocLNmilHO8smbttWwuXUGlnlW+DpIAK7DNih3aHjdFlRbixPqVQLicFMiI5+ixw+EM1ZQr0S59A==} + /@tiptap/extension-bullet-list@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-mZznxwymWitQRHYxEN8LX7theJdQ1/O6kUsvwDyHw42+jaCsZumTHEWGckBwkxk3BWWKbrkRGv/cC78sa3cNJw==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-code-block@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-8Urq8OvdBXDs8cX7Tgw71uyHQknqOeEgU0w4PvhFlOnDKUyehiUGys86k/NUJwP2P9O+YGToM3sVJF0ONdlNGQ==} + /@tiptap/extension-code-block@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-CKn4xqhpCfwkVdkj//A+LVf0hFrRkBbDx8u3KG+I7cegjXxvDSqb2OGhn/tXpFatLAE50GJiPIvqf+TmhIWBvA==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 dev: false - /@tiptap/extension-code@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-nRYeiw9MtV6x3klcQd2X3fJ7r8J1Zy+KHTvfJFYsK8SacSVl+tYi8/FsjbmzGmnO3NYUlDzdyPq/woT+cKk20Q==} + /@tiptap/extension-code@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-CHMHK76fGFrY3TpsyNmPB393VvRgjnvLVOfc0Qx4KKEkntDQ1v2jg90XupLf0+H0aq0KQBHlSooW0Bh+7SxbmQ==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-document@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-Pb0jSY6wYSKd8fgDotKiQN3mc4dKLWVq0IzYOpBGTLn+37WmyPzWCnA1vWjp81u2XRXvr3gFQBEa+zYXonG//w==} + /@tiptap/extension-document@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-eUhpYq8ErVAlxuTg5wslc96mniEQs+VN+tFmRrx9Q0n0nG/aDKUQFDgcSMpAMpHK7+h7tGc/rDq+ydpzZhFXlQ==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-dropcursor@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-Y9MmqRv0MMzhy8efVvHV1zAwr4oEiodggYOLC8Vav0IubWXqd8mVDCdz3DjEqGOdeR9Qy/zt4ohQlbkRno9twA==} + /@tiptap/extension-dropcursor@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-HxXEf6m+W3PnT63Ib49qAmcwmapZvmyWgq9cvB5kSfl/znQT04wBgShEigkgUBLqgcM/R/RI8NS1GQl1Zpv9iQ==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 dev: false - /@tiptap/extension-floating-menu@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-2lQaEIUbexw8yCGDLxNqhCwpZMoclnDtAdObDKCgufLcA+tVSdVirfcx7HJZ4kmRXmUhdG3uvawQVchZJ7FGiw==} + /@tiptap/extension-floating-menu@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-DRz9kzcPt7S8s22EQC+KS/ghnHRV6j7Qequ+0kLjfLYPdqj2u4G5xTrFM7sWfzUqf2HdH8SS8Yo9WFMYm69D9w==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 tippy.js: 6.3.7 dev: false - /@tiptap/extension-gapcursor@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-/OCQDwa1/LwFVEFWCkU4dnUt4pGt1IJKOEzT2JETrRQB4VzP1flIpwPAy5tJ3i2abkCZtnEGOJ5ZsXC01LgOxg==} + /@tiptap/extension-gapcursor@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-qsE8yI9nZOLHg6XdFwn4BYMhR2f/50gppHJdsHx53575y2ci6uowMI+WjdEentl6yR9ctgV1jelHLs9ShmPzwQ==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 dev: false - /@tiptap/extension-hard-break@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-wKRujmJlaBOv+PEnaUL2C9fEjiq1YDsm/SbHzAPYn/+8CRHOLrcJpUAq9rUo7u6+bEBD2PxbN1pPNrfS3QtPLw==} + /@tiptap/extension-hard-break@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-zbG6/7xyMim2fnRESIx2FiFHjdY7BXKMe+GUgLGPnRfXrJqSZhdVguBrtYGBnBFCnuSiOZZ6rFy+k5uORGSrhA==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-heading@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-BI14qTOWTcabvobSo8QBAXQqmz4vQ9HjInK9btKEAJ027BHFk08n6YoovQDcTH5Z2nvwPN4N3RW0JupmgwflAQ==} + /@tiptap/extension-heading@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-oCd8VsLnrqJFY+lgA+5I/2EjBa4mQzB5DFLzCI460PfZnQJ2DmaNUdpY38BpHUv8E2PbBXzxxWS9h88yycW6yw==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-history@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-MZyIKWgNLzsEysyzWwsUE705+x4GRuLluS5cWGzZ/CKjSEUO152DVlM50MPpKqH+o1SW3cR7RUvDPJlpM7Lymw==} + /@tiptap/extension-history@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-hcCEh7mP5H38ZY3YtbyyUOTNfKWAvITkJhVqjKbrRI3E+FOlG3pWPH3wz4srW5bHK38oUsiKwyP9FqC3C2Mixg==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 dev: false - /@tiptap/extension-horizontal-rule@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-M6mi6CPMeO/1i/0qqEIVbObB3VDT6HhO4cxvjiaWxRXsU+79kdvx33Mk6GHKuXZizAYN1qxeM7byAtFiRu/aIw==} + /@tiptap/extension-horizontal-rule@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-5hun56M9elO6slOoDH03q2of06KB1rX8MLvfiKpfAvjbhmuQJav20fz2MQ2lCunek0D8mUIySwhfMvBrTcd90A==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 dev: false - /@tiptap/extension-italic@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-x3cWWgup7SZIq8ugfJ8IFpvOROwU0KuAZFJlSk9eL9Qszff2ZG8kPUuQ8HklSdmoafSluv0mUhDSFTc52E/Yzg==} + /@tiptap/extension-italic@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-l9NZK4vYqYY9Y5UskLQpdbvi0sXG4I/MuhRxPdjitK8E3SVhZxMnoNwCTkq0+I1xBjCD/jSrDMV4FqkKesrl2w==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-link@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-3oh+tRFGtj3Scv3i6FBJOakX650NBI+cmemJxggaiH8PPw3+q+eZneqDQHqINcgel5/HQXFdXWSpfYsZi7EDoQ==} + /@tiptap/extension-link@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-hk2cxSWeFagv2erxVI4UUN9kTLqhTSLhtHKVNbKOW50dtkDqjzp9tri1+LYYpiObxDKoFFKfKjE6ojVtqMyn2w==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 linkifyjs: 4.1.3 dev: false - /@tiptap/extension-list-item@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-6seOMXbji+ptW9OiOig8BVBxnGZhHgWEU6tmsMxsf61dt8b1CElf04oTbvn1hzkCjQQXJbsztU87Q7FLTfLE0w==} + /@tiptap/extension-list-item@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-VuHlbhLePXvKTx55X0iIZ1EXARAoOf6lpbKJK8180jny2gpYxGhk7rwG1G8s6G6ZDST+kyVa04gncxz8F/z6oA==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-ordered-list@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-6OcdBALgYnj/TKSV+ouT202U1ydJGenGL9ofhpdogC1scE0oMaR25f1T9k7RMUGAv+7FNIcVZp2+bf1SLCTX1A==} + /@tiptap/extension-ordered-list@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-TgG+mJyQB5CfeqCD65B9CLesl2IQTjc7tAKm8ZxRzF80GrCrmWNnoXi424TWmSF6cUV/4TY0G5dTkc9kB+S2tw==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-paragraph@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-SaSnWml4DTzG9kTEq/diL6XQGSgNaZJPOIGTPL2/1idHrx4/JrZ87VIbChJwVqbDuAvAxbidAqxKJuYObSzhhg==} + /@tiptap/extension-paragraph@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-USTzajni/hsQXsBF0Lbw++FyPJKCDlROyaKbZi77QQoUsU2MbJIka7k4tGc0kwyTB04aAl+E6+0iS4xIhC3rug==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-placeholder@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-QEUEhzvUOLRjdvzm45CGNqdzqNalKjYgnkmdiNr7HPsmXJ+Teoe/dP7HTo2ssGRqkziE9OJrDRu12kCr+H73MQ==} + /@tiptap/extension-placeholder@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-dPN15nVu+HlONJSCiKjEl9n5/61CltTLSefhyRVQJeE7lmtMUGrsErUdOYMxGskehDQWIQW1VM0OiF63ln/3sA==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 dev: false - /@tiptap/extension-strike@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-4XntI9CLteDeu1ZBWqTygiuut2mJbS5E6SxDD0LZ6EtXM1LdTkh5xhDdviErsqlAe1PUtrFcGUAfdyMOdp26uQ==} + /@tiptap/extension-strike@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-0wsqiZPatw9QrK3DJ1jCMukenc8DRQtEXo4/dQjtnzNDhe7ZySed6kPpGO9A4lASG7NV7GmYZ/k5iEELr+iE6Q==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/extension-text@2.2.1(@tiptap/core@2.2.1): - resolution: {integrity: sha512-1zjPuGyhLIODsYpCYeRSUgzfzdzk5uSo8PsV/WaF75ouilJYE3QvMQegivkuNmPBLz0nukPkOcXfQUI/D8wNOA==} + /@tiptap/extension-text@2.2.2(@tiptap/core@2.2.2): + resolution: {integrity: sha512-Zj53Vp/9MSQj5uiaObFaD3y7grUpMy+PfHmrK5XAZSFhRx+QpGUp+oItlKod6IJEIu8rq4dChgE7i6kT9uwWlA==} peerDependencies: '@tiptap/core': ^2.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) dev: false - /@tiptap/pm@2.2.1: - resolution: {integrity: sha512-jfbrvUlDZ5t6zgk7pXT6GtHX7v48PL6K9TfDEzROywJS2iXSt/0iHUi2Ap8u8hvfv4rBDO7m7LVLS0+rRa5Cyg==} + /@tiptap/pm@2.2.2: + resolution: {integrity: sha512-TcUxqevVcqLYOcbAGlmvZfOB5LL5zZmb6jxSHyevl41SRpGZLe9Jt0e1v98jS0o9GMS7nvcTK/scYQu9e0HqTA==} dependencies: prosemirror-changeset: 2.2.1 prosemirror-collab: 1.3.1 @@ -6454,59 +6454,59 @@ packages: prosemirror-view: 1.32.7 dev: false - /@tiptap/react@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-bKrW38l1mezJDAjQzDTOAXu0RFjNkGl7QR0n0tYjLsOinlUjNIKFAmOe9+Qy/bxvfsQWlPDOSSGjRYafe5zvVA==} + /@tiptap/react@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-9jRaY7Clrtb23itFyTGgLEo5SO0shR/kxlFN3G6Wyda6S6SduY9ERX93ffRdvzbJKcbEptcko0KqUZ/MD0eDnA==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/extension-bubble-menu': 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) - '@tiptap/extension-floating-menu': 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/extension-bubble-menu': 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) + '@tiptap/extension-floating-menu': 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false - /@tiptap/starter-kit@2.2.1(@tiptap/pm@2.2.1): - resolution: {integrity: sha512-x6Xm5KnecorskpCSdDxaPDU2kSB4cWCy/DCEANbOrVyj1ukg8mEnVPkW6/v4LzqJHZb0Ah9rKVOQw9oOeoFb9A==} - dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/extension-blockquote': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-bold': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-bullet-list': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-code': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-code-block': 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) - '@tiptap/extension-document': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-dropcursor': 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) - '@tiptap/extension-gapcursor': 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) - '@tiptap/extension-hard-break': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-heading': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-history': 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) - '@tiptap/extension-horizontal-rule': 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) - '@tiptap/extension-italic': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-list-item': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-ordered-list': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-paragraph': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-strike': 2.2.1(@tiptap/core@2.2.1) - '@tiptap/extension-text': 2.2.1(@tiptap/core@2.2.1) + /@tiptap/starter-kit@2.2.2(@tiptap/pm@2.2.2): + resolution: {integrity: sha512-J8nbrVBggGJwO7CPEwdUqG6Q8btiQJjjnYWZEs+ImM9GMUfXJ8lyaGT0My3wDvTeq537N9BjTEcQ88pMtOqbOw==} + dependencies: + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/extension-blockquote': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-bold': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-bullet-list': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-code': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-code-block': 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) + '@tiptap/extension-document': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-dropcursor': 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) + '@tiptap/extension-gapcursor': 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) + '@tiptap/extension-hard-break': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-heading': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-history': 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) + '@tiptap/extension-horizontal-rule': 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) + '@tiptap/extension-italic': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-list-item': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-ordered-list': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-paragraph': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-strike': 2.2.2(@tiptap/core@2.2.2) + '@tiptap/extension-text': 2.2.2(@tiptap/core@2.2.2) transitivePeerDependencies: - '@tiptap/pm' dev: false - /@tiptap/vue-3@2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1)(vue@3.4.15): - resolution: {integrity: sha512-vRYTPnpKJBrpQEjj+7WDx7AKcjNskOlHMEfgCPlwFMG6o1dwbDDtXWj9U53SyiZfitRkudb7WAbX0RyleBjdIQ==} + /@tiptap/vue-3@2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2)(vue@3.4.15): + resolution: {integrity: sha512-4QNjruL7qiOgSANczipKtbYmMZS/gGuqV2UeBKKXTXIGFr5qA5R5n9Icy+El0oJOUZ8MNDFOVSGaHh/ts+pY3g==} peerDependencies: '@tiptap/core': ^2.0.0 '@tiptap/pm': ^2.0.0 vue: ^3.0.0 dependencies: - '@tiptap/core': 2.2.1(@tiptap/pm@2.2.1) - '@tiptap/extension-bubble-menu': 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) - '@tiptap/extension-floating-menu': 2.2.1(@tiptap/core@2.2.1)(@tiptap/pm@2.2.1) - '@tiptap/pm': 2.2.1 + '@tiptap/core': 2.2.2(@tiptap/pm@2.2.2) + '@tiptap/extension-bubble-menu': 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) + '@tiptap/extension-floating-menu': 2.2.2(@tiptap/core@2.2.2)(@tiptap/pm@2.2.2) + '@tiptap/pm': 2.2.2 vue: 3.4.15(typescript@5.3.3) dev: false From 0c5a9247644d4bc1bd7c32e21e4dfc065c25408e Mon Sep 17 00:00:00 2001 From: Jaehyeon Kim Date: Thu, 15 Feb 2024 15:07:49 +0900 Subject: [PATCH 08/14] feat(be): implement get-contest api (#1370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(be): implement get-contest api - 참가자 수 participants를 함께 반환하는 getContest api 구현 * docs(be): add get-contest api docs * chore(be): modify get-contest, get-contests to include participants - _count를 활용해 참가자수가 contest 정보에 포함되도록 설정 * test(be): modify test to use contest-with-participants * chore(be): remove unused pipe --- .../admin/src/contest/contest.resolver.ts | 30 +++++++++-- .../admin/src/contest/contest.service.spec.ts | 54 ++++++++++++++++--- .../apps/admin/src/contest/contest.service.ts | 37 ++++++++++++- .../model/contest-with-participants.model.ts | 8 +++ .../admin/Contest/Get Contest/NOT_FOUND.bru | 32 +++++++++++ .../admin/Contest/Get Contest/Succeed.bru | 40 ++++++++++++++ .../admin/Contest/Get Contests/Succeed.bru | 1 + 7 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 backend/apps/admin/src/contest/model/contest-with-participants.model.ts create mode 100644 collection/admin/Contest/Get Contest/NOT_FOUND.bru create mode 100644 collection/admin/Contest/Get Contest/Succeed.bru diff --git a/backend/apps/admin/src/contest/contest.resolver.ts b/backend/apps/admin/src/contest/contest.resolver.ts index 071f25e421..6dbc9338ce 100644 --- a/backend/apps/admin/src/contest/contest.resolver.ts +++ b/backend/apps/admin/src/contest/contest.resolver.ts @@ -1,11 +1,13 @@ import { InternalServerErrorException, Logger, + NotFoundException, ParseBoolPipe } from '@nestjs/common' import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql' import { ContestProblem } from '@generated' import { Contest } from '@generated' +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' import { AuthenticatedRequest, UseRolesGuard } from '@libs/auth' import { OPEN_SPACE_ID } from '@libs/constants' import { @@ -15,6 +17,7 @@ import { } from '@libs/exception' import { CursorValidationPipe, GroupIDPipe, RequiredIntPipe } from '@libs/pipe' import { ContestService } from './contest.service' +import { ContestWithParticipants } from './model/contest-with-participants.model' import { CreateContestInput } from './model/contest.input' import { UpdateContestInput } from './model/contest.input' import { PublicizingRequest } from './model/publicizing-request.model' @@ -25,7 +28,7 @@ export class ContestResolver { private readonly logger = new Logger(ContestResolver.name) constructor(private readonly contestService: ContestService) {} - @Query(() => [Contest]) + @Query(() => [ContestWithParticipants]) async getContests( @Args( 'take', @@ -45,6 +48,23 @@ export class ContestResolver { return await this.contestService.getContests(take, groupId, cursor) } + @Query(() => ContestWithParticipants) + async getContest( + @Args('contestId', { type: () => Int }, new RequiredIntPipe('contestId')) + contestId: number + ) { + try { + return await this.contestService.getContest(contestId) + } catch (error) { + if ( + error instanceof PrismaClientKnownRequestError && + error.code == 'P2025' + ) { + throw new NotFoundException(error.message) + } + } + } + @Mutation(() => Contest) async createContest( @Args('input') input: CreateContestInput, @@ -96,7 +116,7 @@ export class ContestResolver { @Mutation(() => Contest) async deleteContest( @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, - @Args('contestId', { type: () => Int }, new RequiredIntPipe('contestId')) + @Args('contestId', { type: () => Int }) contestId: number ) { try { @@ -119,7 +139,7 @@ export class ContestResolver { @Mutation(() => PublicizingRequest) async createPublicizingRequest( @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, - @Args('contestId', { type: () => Int }, new RequiredIntPipe('contestId')) + @Args('contestId', { type: () => Int }) contestId: number ) { try { @@ -142,7 +162,7 @@ export class ContestResolver { @Mutation(() => PublicizingResponse) @UseRolesGuard() async handlePublicizingRequest( - @Args('contestId', { type: () => Int }, new RequiredIntPipe('contestId')) + @Args('contestId', { type: () => Int }) contestId: number, @Args('isAccepted', ParseBoolPipe) isAccepted: boolean ) { @@ -163,7 +183,7 @@ export class ContestResolver { @Mutation(() => [ContestProblem]) async importProblemsToContest( @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, - @Args('contestId', { type: () => Int }, new RequiredIntPipe('contestId')) + @Args('contestId', { type: () => Int }) contestId: number, @Args('problemIds', { type: () => [Int] }) problemIds: number[] ) { diff --git a/backend/apps/admin/src/contest/contest.service.spec.ts b/backend/apps/admin/src/contest/contest.service.spec.ts index 2fca5c01cb..e932af6073 100644 --- a/backend/apps/admin/src/contest/contest.service.spec.ts +++ b/backend/apps/admin/src/contest/contest.service.spec.ts @@ -11,6 +11,7 @@ import { stub } from 'sinon' import { EntityNotExistException } from '@libs/exception' import { PrismaService } from '@libs/prisma' import { ContestService } from './contest.service' +import type { ContestWithParticipants } from './model/contest-with-participants.model' import type { CreateContestInput, UpdateContestInput @@ -21,6 +22,10 @@ const contestId = 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 contest: Contest = { id: contestId, @@ -28,14 +33,51 @@ const contest: Contest = { groupId, title: 'title', description: 'description', - startTime: faker.date.past(), - endTime: faker.date.future(), + startTime, + endTime, config: { isVisible: true, isRankVisible: true }, - createTime: faker.date.past(), - updateTime: faker.date.past() + createTime, + updateTime +} + +const contestWithCount = { + id: contestId, + createdById: userId, + groupId, + title: 'title', + description: 'description', + startTime, + endTime, + config: { + isVisible: true, + isRankVisible: true + }, + createTime, + updateTime, + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + contestRecord: 10 + } +} + +const contestWithParticipants: ContestWithParticipants = { + id: contestId, + createdById: userId, + groupId, + title: 'title', + description: 'description', + startTime, + endTime, + config: { + isVisible: true, + isRankVisible: true + }, + createTime, + updateTime, + participants: 10 } const group: Group = { @@ -175,10 +217,10 @@ describe('ContestService', () => { describe('getContests', () => { it('should return an array of contests', async () => { - db.contest.findMany.resolves([contest]) + db.contest.findMany.resolves([contestWithCount]) const res = await service.getContests(5, 2, 0) - expect(res).to.deep.equal([contest]) + expect(res).to.deep.equal([contestWithParticipants]) }) }) diff --git a/backend/apps/admin/src/contest/contest.service.ts b/backend/apps/admin/src/contest/contest.service.ts index 956d806ad1..d0b1e86bab 100644 --- a/backend/apps/admin/src/contest/contest.service.ts +++ b/backend/apps/admin/src/contest/contest.service.ts @@ -33,11 +33,44 @@ export class ContestService { async getContests(take: number, groupId: number, cursor: number | null) { const paginator = this.prisma.getPaginator(cursor) - return await this.prisma.contest.findMany({ + const contests = await this.prisma.contest.findMany({ ...paginator, where: { groupId }, - take + take, + include: { + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + select: { contestRecord: true } + } + } + }) + + return contests.map((contest) => { + const { _count, ...data } = contest + return { + ...data, + participants: _count.contestRecord + } + }) + } + + async getContest(contestId: number) { + const { _count, ...data } = await this.prisma.contest.findFirstOrThrow({ + where: { + id: contestId + }, + include: { + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + select: { contestRecord: true } + } + } }) + + return { + ...data, + participants: _count.contestRecord + } } async createContest( diff --git a/backend/apps/admin/src/contest/model/contest-with-participants.model.ts b/backend/apps/admin/src/contest/model/contest-with-participants.model.ts new file mode 100644 index 0000000000..9fcd0fd275 --- /dev/null +++ b/backend/apps/admin/src/contest/model/contest-with-participants.model.ts @@ -0,0 +1,8 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Contest } from '@admin/@generated' + +@ObjectType({ description: 'contestWithParticipants' }) +export class ContestWithParticipants extends Contest { + @Field(() => Int) + participants: number +} diff --git a/collection/admin/Contest/Get Contest/NOT_FOUND.bru b/collection/admin/Contest/Get Contest/NOT_FOUND.bru new file mode 100644 index 0000000000..b041e54bba --- /dev/null +++ b/collection/admin/Contest/Get Contest/NOT_FOUND.bru @@ -0,0 +1,32 @@ +meta { + name: NOT_FOUND + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query GetContest($contestId: Int!) { + getContest(contestId: $contestId) { + id + title + participants + } + } +} + +body:graphql:vars { + { + "contestId": 99999 + } +} + +assert { + res.body.errors[0].message: eq No Contest found + res.body.errors[0].extensions.code: eq NOT_FOUND +} diff --git a/collection/admin/Contest/Get Contest/Succeed.bru b/collection/admin/Contest/Get Contest/Succeed.bru new file mode 100644 index 0000000000..54780a3560 --- /dev/null +++ b/collection/admin/Contest/Get Contest/Succeed.bru @@ -0,0 +1,40 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query GetContest($contestId: Int!) { + getContest(contestId: $contestId) { + id + title + participants + } + } +} + +body:graphql:vars { + { + "contestId": 1 + } +} + +assert { + res.body.data.getContest: isDefined +} + +docs { + ## Get Problem + 참가자수 정보를 포함한 Problem 정보를 가져옵니다. + + ### Error Cases + #### NOT_FOUND + 존재하는 contestId를 전달해야 합니다. +} diff --git a/collection/admin/Contest/Get Contests/Succeed.bru b/collection/admin/Contest/Get Contests/Succeed.bru index 5c9188d623..97458d7f03 100644 --- a/collection/admin/Contest/Get Contests/Succeed.bru +++ b/collection/admin/Contest/Get Contests/Succeed.bru @@ -22,6 +22,7 @@ body:graphql { description startTime endTime + participants } } } From c14e39023b764195650ea90a0b0c13c385965cb9 Mon Sep 17 00:00:00 2001 From: Jaehyeon Kim Date: Thu, 15 Feb 2024 15:09:57 +0900 Subject: [PATCH 09/14] chore(be): add is-visible field to problem model (#1366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(be): add is-visible field to problem model * chore(be): modify problem input type - problem input type에서 visible 여부 설정 가능하도록 변경 * docs(be): modify admin create-problem api docs - input에 isVisible 설정 반영 * test(be): add is-visible to input * test(be): add is-visible to problem input * feat(be): add update-visibility api * docs(be): add update-visibility api docs * chore(be): delete useless update-visibility function * chore(be): modify get-problem functions to check is-visible Co-authored-by: SH9480P --- .../admin/src/contest/contest.service.spec.ts | 1 + backend/apps/admin/src/problem/mock/mock.ts | 12 ++++-- .../admin/src/problem/model/problem.input.ts | 6 +++ .../admin/src/problem/problem.service.spec.ts | 1 + .../client/src/problem/mock/problem.mock.ts | 6 ++- .../client/src/problem/problem.repository.ts | 39 +++++++++++++++---- .../src/submission/mock/problem.mock.ts | 3 +- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + .../admin/Problem/Create Problem/Succeed.bru | 5 +++ .../Create Problem/UNPROCESSABLE (1).bru | 1 + .../Create Problem/UNPROCESSABLE (2).bru | 1 + .../Problem/Update a Problem/Succeed.bru | 4 +- 13 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 backend/prisma/migrations/20240213051227_add_is_visible_to_problem_model/migration.sql diff --git a/backend/apps/admin/src/contest/contest.service.spec.ts b/backend/apps/admin/src/contest/contest.service.spec.ts index e932af6073..4b6f0f4ca9 100644 --- a/backend/apps/admin/src/contest/contest.service.spec.ts +++ b/backend/apps/admin/src/contest/contest.service.spec.ts @@ -103,6 +103,7 @@ const problem: Problem = { inputDescription: 'inputdescription', outputDescription: 'outputdescription', hint: 'hint', + isVisible: true, template: [], languages: ['C'], timeLimit: 10000, diff --git a/backend/apps/admin/src/problem/mock/mock.ts b/backend/apps/admin/src/problem/mock/mock.ts index e07ac5fbaf..12974ba5a4 100644 --- a/backend/apps/admin/src/problem/mock/mock.ts +++ b/backend/apps/admin/src/problem/mock/mock.ts @@ -42,7 +42,8 @@ export const problems: Problem[] = [ updateTime: faker.date.past(), exposeTime: faker.date.anytime(), inputExamples: [], - outputExamples: [] + outputExamples: [], + isVisible: true }, { id: 2, @@ -66,7 +67,8 @@ export const problems: Problem[] = [ updateTime: faker.date.past(), exposeTime: faker.date.anytime(), inputExamples: [], - outputExamples: [] + outputExamples: [], + isVisible: true } ] @@ -119,7 +121,8 @@ export const importedProblems: Problem[] = [ updateTime: faker.date.past(), exposeTime: faker.date.anytime(), inputExamples: [], - outputExamples: [] + outputExamples: [], + isVisible: true }, { id: 33, @@ -159,6 +162,7 @@ export const importedProblems: Problem[] = [ updateTime: faker.date.past(), exposeTime: faker.date.anytime(), inputExamples: [], - outputExamples: [] + outputExamples: [], + isVisible: true } ] diff --git a/backend/apps/admin/src/problem/model/problem.input.ts b/backend/apps/admin/src/problem/model/problem.input.ts index fe5b34ea18..b227ed7340 100644 --- a/backend/apps/admin/src/problem/model/problem.input.ts +++ b/backend/apps/admin/src/problem/model/problem.input.ts @@ -23,6 +23,9 @@ export class CreateProblemInput { @Field(() => String, { nullable: false }) hint!: string + @Field(() => Boolean, { defaultValue: true }) + isVisible!: boolean + @Field(() => [Template], { nullable: false }) template!: Array