Skip to content

Commit

Permalink
feat: implement duplicate contest (#1950)
Browse files Browse the repository at this point in the history
* feat(be): add duplicate contest api

* feat(fe): add duplicatecontest alertdialog component

* fix(fe): change copyicon, copyCompleteIcon misusage

* feat(fe): connect duplicateContest api

* feat(fe): duplicate contest feature

* feat(fe): fix duplicate contest modal design

* fix(be): change groupId, contestId

* feat(fe): reload after duplicate contest, toast

* fix(fe): add newVisible to contest api

* docs(be): add subquery to duplicatecontest api

* feat(be): apply transaction

* chore(be): disable duplicate contest test

---------

Co-authored-by: Jaehyeon Kim <[email protected]>
Co-authored-by: Jaehyeon Kim <[email protected]>
  • Loading branch information
3 people authored Aug 18, 2024
1 parent a581ef5 commit 2544667
Show file tree
Hide file tree
Showing 11 changed files with 529 additions and 52 deletions.
26 changes: 26 additions & 0 deletions apps/backend/apps/admin/src/contest/contest.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ 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 { DuplicatedContestResponse } from './model/duplicated-contest-response.output'
import { PublicizingRequest } from './model/publicizing-request.model'
import { PublicizingResponse } from './model/publicizing-response.output'
import { UserContestScoreSummary } from './model/score-summary'
Expand Down Expand Up @@ -220,6 +221,31 @@ export class ContestResolver {
}
}

@Mutation(() => DuplicatedContestResponse)
async duplicateContest(
@Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number,
@Args('contestId', { type: () => Int })
contestId: number,
@Context('req') req: AuthenticatedRequest
) {
try {
return await this.contestService.duplicateContest(
groupId,
contestId,
req.user.id
)
} catch (error) {
if (
error instanceof UnprocessableDataException ||
error instanceof EntityNotExistException
) {
throw error.convert2HTTPException()
}
this.logger.error(error)
throw new InternalServerErrorException()
}
}

@Query(() => UserContestScoreSummary)
async getScoreSummaries(
@Args('userId', { type: () => Int }) userId: number,
Expand Down
63 changes: 61 additions & 2 deletions apps/backend/apps/admin/src/contest/contest.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager'
import { Test, type TestingModule } from '@nestjs/testing'
import { ContestProblem, Group } from '@generated'
import { ContestProblem, Group, ContestRecord } from '@generated'
import { Problem } from '@generated'
import { Contest } from '@generated'
import { faker } from '@faker-js/faker'
Expand All @@ -27,6 +27,7 @@ const endTime = faker.date.future()
const createTime = faker.date.past()
const updateTime = faker.date.past()
const invitationCode = '123456'
// const duplicatedContestId = 2

const contest: Contest = {
id: contestId,
Expand Down Expand Up @@ -175,7 +176,12 @@ const db = {
delete: stub().resolves(Contest)
},
contestProblem: {
create: stub().resolves(ContestProblem)
create: stub().resolves(ContestProblem),
findMany: stub().resolves([ContestProblem])
},
contestRecord: {
findMany: stub().resolves([ContestRecord]),
create: stub().resolves(ContestRecord)
},
problem: {
update: stub().resolves(Problem),
Expand Down Expand Up @@ -348,4 +354,57 @@ describe('ContestService', () => {
).to.be.rejectedWith(EntityNotExistException)
})
})

// describe('duplicateContest', () => {
// db['$transaction'] = stub().callsFake(async () => {
// const newContest = await db.contest.create()
// const newContestProblem = await db.contestProblem.create()
// const newContestRecord = await db.contestRecord.create()
// return [newContest, newContestProblem, newContestRecord]
// })

// it('should return duplicated contest', async () => {
// db.contest.findFirst.resolves(contest)
// db.contestProblem.create.resolves({
// ...contest,
// createdById: userId,
// groupId,
// isVisible: false
// })
// db.contestProblem.findMany.resolves([contestProblem])
// db.contestProblem.create.resolves({
// ...contestProblem,
// contestId: duplicatedContestId
// })
// db.contestRecord.findMany.resolves([contestRecord])
// db.contestRecord.create.resolves({
// ...contestRecord,
// contestId: duplicatedContestId
// })

// const res = await service.duplicateContest(groupId, contestId, userId)
// expect(res.contest).to.deep.equal(contest)
// expect(res.problems).to.deep.equal([
// {
// ...contestProblem,
// contestId: duplicatedContestId
// }
// ])
// expect(res.records).to.deep.equal([
// { ...contestRecord, contestId: duplicatedContestId }
// ])
// })

// it('should throw error when the contestId not exist', async () => {
// expect(
// service.duplicateContest(groupId, 9999, userId)
// ).to.be.rejectedWith(EntityNotExistException)
// })

// it('should throw error when the groupId not exist', async () => {
// expect(
// service.duplicateContest(9999, contestId, userId)
// ).to.be.rejectedWith(EntityNotExistException)
// })
// })
})
95 changes: 94 additions & 1 deletion apps/backend/apps/admin/src/contest/contest.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,100 @@ export class ContestService {
}

/**
* 특정 User의 특정 Contest에 대한 총점, 통과한 문제 개수와 각 문제별 테스트케이스 통과 개수를 불러옵니다.
* Duplicate contest with contest problems and users who participated in the contest
* Not copied: submission
* @param groupId group to duplicate contest
* @param contestId contest to duplicate
* @param userId user who tries to duplicates the contest
* @returns
*/
async duplicateContest(groupId: number, contestId: number, userId: number) {
const [contestFound, contestProblemsFound, userContestRecords] =
await Promise.all([
this.prisma.contest.findFirst({
where: {
id: contestId,
groupId
}
}),
this.prisma.contestProblem.findMany({
where: {
contestId
}
}),
this.prisma.contestRecord.findMany({
where: {
contestId
}
})
])

if (!contestFound) {
throw new EntityNotExistException('contest')
}

// if contest status is ongoing, visible would be true. else, false
const now = new Date()
let newVisible = false
if (contestFound.startTime <= now && now <= contestFound.endTime) {
newVisible = true
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, createTime, updateTime, title, ...contestDataToCopy } =
contestFound

const [newContest, newContestProblems, newContestRecords] =
await this.prisma.$transaction(async (tx) => {
// 1. copy contest
const newContest = await tx.contest.create({
data: {
...contestDataToCopy,
title: 'Copy of ' + title,
createdById: userId,
groupId,
isVisible: newVisible
}
})

// 2. copy contest problems
const newContestProblems = await Promise.all(
contestProblemsFound.map((contestProblem) =>
tx.contestProblem.create({
data: {
order: contestProblem.order,
contestId: newContest.id,
problemId: contestProblem.problemId
}
})
)
)

// 3. copy contest records (users who participated in the contest)
const newContestRecords = await Promise.all(
userContestRecords.map((userContestRecord) =>
tx.contestRecord.create({
data: {
contestId: newContest.id,
userId: userContestRecord.userId
}
})
)
)

return [newContest, newContestProblems, newContestRecords]
})

return {
contest: newContest,
problems: newContestProblems,
records: newContestRecords
}
}

/**
*
* 특정 user의 특정 Contest에 대한 총점, 통과한 문제 개수와 각 문제별 테스트케이스 통과 개수를 불러옵니다.
*/
async getContestScoreSummary(userId: number, contestId: number) {
const [contestProblems, submissions] = await Promise.all([
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Field, ObjectType } from '@nestjs/graphql'
import { Type } from 'class-transformer'
import { Contest, ContestProblem, ContestRecord } from '@admin/@generated'

@ObjectType()
export class DuplicatedContestResponse {
@Field(() => Contest)
contest: Contest

@Field(() => [ContestProblem])
problems: ContestProblem[]

@Field(() => [ContestRecord])
@Type(() => ContestRecord)
records: ContestRecord[]
}
124 changes: 124 additions & 0 deletions apps/frontend/app/admin/contest/_components/DuplicateContest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use client'

import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { DUPLICATE_CONTEST } from '@/graphql/contest/mutations'
import { useMutation } from '@apollo/client'
import { CopyIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'

export default function DuplicateContest({
groupId,
contestId,
contestStatus
}: {
groupId: number
contestId: number
contestStatus: string
}) {
const router = useRouter()
const [duplicateContest, { error }] = useMutation(DUPLICATE_CONTEST)

const duplicateContestById = async () => {
if (error) {
console.error(error)
return
}
const toastId = toast.loading('Duplicating contest...')

const res = await duplicateContest({
variables: {
groupId,
contestId
}
})

if (res.data?.duplicateContest.contest) {
toast.success(
`Contest duplicated completed.\n Duplicated contest title: ${res.data.duplicateContest.contest.title}`,
{
id: toastId
}
)
}
router.refresh()
}

return (
<div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="default" size="default">
<CopyIcon className="mr-2 h-4 w-4" />
Duplicate
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="border-slate-80 border bg-white">
<AlertDialogHeader>
<AlertDialogTitle className="text-xl font-bold text-black">
Duplicate {contestStatus === 'ongoing' ? 'Ongoing ' : ''}Contest
</AlertDialogTitle>
<AlertDialogDescription className="text-neutral-500">
<p className="font-semibold">Contents That Will Be Copied:</p>
<ul className="mb-3 ml-5 list-disc">
<li>Title</li>
<li>Start Time & End Time</li>
<li>Description</li>
<li>
Contest Security Settings (invitation code, allow copy/paste)
</li>
<li>Contest Problems</li>
<li className="text-red-500">
Participants of the selected contest <br />
<span className="text-xs">
(All participants of the selected contest will be
automatically registered for the duplicated contest.)
</span>
</li>
</ul>
<p className="font-semibold">Contents That Will Not Be Copied:</p>
<ul className="mb-3 ml-5 list-disc">
<li>Users&apos; Submissions</li>
</ul>
{contestStatus === 'ongoing' ? (
<p className="mb-3 mt-4 text-red-500">
Caution: The new contest will be set to visible.
</p>
) : (
<p className="mb-3 mt-4 text-red-500">
Caution: The new contest will be set to invisible
</p>
)}
<p className="mt-4">
Are you sure you want to proceed with duplicating the selected
contest?
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex justify-end gap-2">
<AlertDialogCancel className="rounded-md px-4 py-2">
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="rounded-md bg-blue-500 px-4 py-2 text-white"
onClick={duplicateContestById}
>
Duplicate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
1 change: 1 addition & 0 deletions apps/frontend/app/admin/contest/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default function Page() {
enableSearch={true}
enableDelete={true}
enablePagination={true}
enableDuplicate={true}
/>
)}
</div>
Expand Down
Loading

0 comments on commit 2544667

Please sign in to comment.