diff --git a/__dummy__/getExercisesData.ts b/__dummy__/getExercisesData.ts index b3ee44d86..9c66a4324 100644 --- a/__dummy__/getExercisesData.ts +++ b/__dummy__/getExercisesData.ts @@ -92,7 +92,8 @@ const getExercisesData: GetExercisesQuery = { answer: '-1', explanation: null } - ] + ], + exerciseSubmissions: [{ exerciseId: 1, userAnswer: '15' }] } export default getExercisesData diff --git a/graphql/index.tsx b/graphql/index.tsx index 4225add71..7065d3348 100644 --- a/graphql/index.tsx +++ b/graphql/index.tsx @@ -87,6 +87,14 @@ export type Exercise = { testStr?: Maybe } +export type ExerciseSubmission = { + __typename?: 'ExerciseSubmission' + exerciseId: Scalars['Int'] + id: Scalars['Int'] + userAnswer: Scalars['String'] + userId: Scalars['Int'] +} + export type Lesson = { __typename?: 'Lesson' challenges: Array @@ -120,6 +128,7 @@ export type Mutation = { addAlert?: Maybe>> addComment?: Maybe addExercise: Exercise + addExerciseSubmission: ExerciseSubmission addModule: Module changeAdminRights?: Maybe changePw?: Maybe @@ -174,6 +183,11 @@ export type MutationAddExerciseArgs = { testStr?: InputMaybe } +export type MutationAddExerciseSubmissionArgs = { + exerciseId: Scalars['Int'] + userAnswer: Scalars['String'] +} + export type MutationAddModuleArgs = { content: Scalars['String'] lessonId: Scalars['Int'] @@ -315,6 +329,7 @@ export type Query = { __typename?: 'Query' alerts: Array allUsers?: Maybe>> + exerciseSubmissions: Array exercises: Array getLessonMentors?: Maybe>> getPreviousSubmissions?: Maybe> @@ -840,6 +855,11 @@ export type GetExercisesQuery = { lesson: { __typename?: 'Lesson'; slug: string } } }> + exerciseSubmissions: Array<{ + __typename?: 'ExerciseSubmission' + exerciseId: number + userAnswer: string + }> } export type GetFlaggedExercisesQueryVariables = Exact<{ [key: string]: never }> @@ -1399,6 +1419,7 @@ export type ResolversTypes = ResolversObject<{ Challenge: ResolverTypeWrapper Comment: ResolverTypeWrapper Exercise: ResolverTypeWrapper + ExerciseSubmission: ResolverTypeWrapper Int: ResolverTypeWrapper Lesson: ResolverTypeWrapper Module: ResolverTypeWrapper @@ -1423,6 +1444,7 @@ export type ResolversParentTypes = ResolversObject<{ Challenge: Challenge Comment: Comment Exercise: Exercise + ExerciseSubmission: ExerciseSubmission Int: Scalars['Int'] Lesson: Lesson Module: Module @@ -1523,6 +1545,17 @@ export type ExerciseResolvers< __isTypeOf?: IsTypeOfResolverFn }> +export type ExerciseSubmissionResolvers< + ContextType = Context, + ParentType extends ResolversParentTypes['ExerciseSubmission'] = ResolversParentTypes['ExerciseSubmission'] +> = ResolversObject<{ + exerciseId?: Resolver + id?: Resolver + userAnswer?: Resolver + userId?: Resolver + __isTypeOf?: IsTypeOfResolverFn +}> + export type LessonResolvers< ContextType = Context, ParentType extends ResolversParentTypes['Lesson'] = ResolversParentTypes['Lesson'] @@ -1599,6 +1632,15 @@ export type MutationResolvers< 'answer' | 'description' | 'moduleId' > > + addExerciseSubmission?: Resolver< + ResolversTypes['ExerciseSubmission'], + ParentType, + ContextType, + RequireFields< + MutationAddExerciseSubmissionArgs, + 'exerciseId' | 'userAnswer' + > + > addModule?: Resolver< ResolversTypes['Module'], ParentType, @@ -1780,6 +1822,11 @@ export type QueryResolvers< ParentType, ContextType > + exerciseSubmissions?: Resolver< + Array, + ParentType, + ContextType + > exercises?: Resolver< Array, ParentType, @@ -1952,6 +1999,7 @@ export type Resolvers = ResolversObject<{ Challenge?: ChallengeResolvers Comment?: CommentResolvers Exercise?: ExerciseResolvers + ExerciseSubmission?: ExerciseSubmissionResolvers Lesson?: LessonResolvers Module?: ModuleResolvers Mutation?: MutationResolvers @@ -3543,6 +3591,10 @@ export const GetExercisesDocument = gql` answer explanation } + exerciseSubmissions { + exerciseId + userAnswer + } } ` export type GetExercisesProps< @@ -5565,6 +5617,19 @@ export type ExerciseFieldPolicy = { module?: FieldPolicy | FieldReadFunction testStr?: FieldPolicy | FieldReadFunction } +export type ExerciseSubmissionKeySpecifier = ( + | 'exerciseId' + | 'id' + | 'userAnswer' + | 'userId' + | ExerciseSubmissionKeySpecifier +)[] +export type ExerciseSubmissionFieldPolicy = { + exerciseId?: FieldPolicy | FieldReadFunction + id?: FieldPolicy | FieldReadFunction + userAnswer?: FieldPolicy | FieldReadFunction + userId?: FieldPolicy | FieldReadFunction +} export type LessonKeySpecifier = ( | 'challenges' | 'chatUrl' @@ -5618,6 +5683,7 @@ export type MutationKeySpecifier = ( | 'addAlert' | 'addComment' | 'addExercise' + | 'addExerciseSubmission' | 'addModule' | 'changeAdminRights' | 'changePw' @@ -5649,6 +5715,7 @@ export type MutationFieldPolicy = { addAlert?: FieldPolicy | FieldReadFunction addComment?: FieldPolicy | FieldReadFunction addExercise?: FieldPolicy | FieldReadFunction + addExerciseSubmission?: FieldPolicy | FieldReadFunction addModule?: FieldPolicy | FieldReadFunction changeAdminRights?: FieldPolicy | FieldReadFunction changePw?: FieldPolicy | FieldReadFunction @@ -5677,6 +5744,7 @@ export type MutationFieldPolicy = { export type QueryKeySpecifier = ( | 'alerts' | 'allUsers' + | 'exerciseSubmissions' | 'exercises' | 'getLessonMentors' | 'getPreviousSubmissions' @@ -5691,6 +5759,7 @@ export type QueryKeySpecifier = ( export type QueryFieldPolicy = { alerts?: FieldPolicy | FieldReadFunction allUsers?: FieldPolicy | FieldReadFunction + exerciseSubmissions?: FieldPolicy | FieldReadFunction exercises?: FieldPolicy | FieldReadFunction getLessonMentors?: FieldPolicy | FieldReadFunction getPreviousSubmissions?: FieldPolicy | FieldReadFunction @@ -5862,6 +5931,13 @@ export type StrictTypedTypePolicies = { | (() => undefined | ExerciseKeySpecifier) fields?: ExerciseFieldPolicy } + ExerciseSubmission?: Omit & { + keyFields?: + | false + | ExerciseSubmissionKeySpecifier + | (() => undefined | ExerciseSubmissionKeySpecifier) + fields?: ExerciseSubmissionFieldPolicy + } Lesson?: Omit & { keyFields?: | false diff --git a/graphql/queries/getExercises.ts b/graphql/queries/getExercises.ts index a7d34a9c2..69c259b3e 100644 --- a/graphql/queries/getExercises.ts +++ b/graphql/queries/getExercises.ts @@ -26,6 +26,10 @@ const GET_EXERCISES = gql` answer explanation } + exerciseSubmissions { + exerciseId + userAnswer + } } ` diff --git a/graphql/resolvers.ts b/graphql/resolvers.ts index f835f2d33..e5f6aefd7 100644 --- a/graphql/resolvers.ts +++ b/graphql/resolvers.ts @@ -39,6 +39,11 @@ import { flagExercise, removeExerciseFlag } from './resolvers/exerciseCrud' +import { + exerciseSubmissions, + addExerciseSubmission +} from './resolvers/exerciseSubmissionCrud' + export default { Query: { submissions, @@ -48,6 +53,7 @@ export default { userInfo, lessons, exercises, + exerciseSubmissions, modules, session, alerts, @@ -66,6 +72,7 @@ export default { addExercise, updateExercise, deleteExercise, + addExerciseSubmission, login, logout, signup, diff --git a/graphql/resolvers/exerciseSubmissionCrud.test.js b/graphql/resolvers/exerciseSubmissionCrud.test.js new file mode 100644 index 000000000..782ebd7b2 --- /dev/null +++ b/graphql/resolvers/exerciseSubmissionCrud.test.js @@ -0,0 +1,95 @@ +/** + * @jest-environment node + */ +import prismaMock from '../../__tests__/utils/prismaMock' +import { + exerciseSubmissions, + addExerciseSubmission +} from './exerciseSubmissionCrud' + +describe('exerciseSubmissions resolver', () => { + test('Should return an empty array if the user is not logged in', () => { + const mockContext = { req: { user: null } } + + expect(exerciseSubmissions(undefined, undefined, mockContext)).toEqual([]) + }) + + test("Should return user's exercise submissions of the logged in user", async () => { + const mockExerciseSubmissions = [{ id: 1 }, { id: 2 }] + const mockContext = { req: { user: { id: 1 } } } + prismaMock.exerciseSubmission.findMany.mockResolvedValue( + mockExerciseSubmissions + ) + + await expect( + exerciseSubmissions(undefined, undefined, mockContext) + ).resolves.toEqual(mockExerciseSubmissions) + expect(prismaMock.exerciseSubmission.findMany).toBeCalledWith({ + where: { userId: 1 } + }) + }) +}) + +describe('addExerciseSubmission resolver', () => { + test("Should throw an error if the user isn't logged in", async () => { + const mockContext = { req: { user: null } } + const mockArgs = { exerciseId: 2, userAnswer: '123' } + + await expect( + addExerciseSubmission(undefined, mockArgs, mockContext) + ).rejects.toEqual(new Error('User should be logged in.')) + }) + + test('Should update an exercise submission if it already exists', async () => { + const mockContext = { req: { user: { id: 1 } } } + const mockArgs = { exerciseId: 2, userAnswer: '123' } + const mockExerciseSubmission = { + id: 1, + exerciseId: 2, + userId: 1, + userAnswer: '123' + } + prismaMock.exerciseSubmission.findFirst.mockResolvedValue( + mockExerciseSubmission + ) + prismaMock.exerciseSubmission.update.mockResolvedValue( + mockExerciseSubmission + ) + + await expect( + addExerciseSubmission(undefined, mockArgs, mockContext) + ).resolves.toEqual(mockExerciseSubmission) + expect(prismaMock.exerciseSubmission.findFirst).toBeCalledWith({ + where: { exerciseId: 2, userId: 1 } + }) + expect(prismaMock.exerciseSubmission.update).toBeCalledWith({ + data: { exerciseId: 2, userAnswer: '123', userId: 1 }, + where: { id: 1 } + }) + }) + + test("Should create a new exercise submission if one doesn't already exist", async () => { + const mockContext = { req: { user: { id: 1 } } } + const mockArgs = { exerciseId: 2, userAnswer: '123' } + const mockExerciseSubmission = { + id: 1, + exerciseId: 2, + userId: 1, + userAnswer: '123' + } + prismaMock.exerciseSubmission.findFirst.mockResolvedValue(null) + prismaMock.exerciseSubmission.create.mockResolvedValue( + mockExerciseSubmission + ) + + await expect( + addExerciseSubmission(undefined, mockArgs, mockContext) + ).resolves.toEqual(mockExerciseSubmission) + expect(prismaMock.exerciseSubmission.findFirst).toBeCalledWith({ + where: { exerciseId: 2, userId: 1 } + }) + expect(prismaMock.exerciseSubmission.create).toBeCalledWith({ + data: { exerciseId: 2, userAnswer: '123', userId: 1 } + }) + }) +}) diff --git a/graphql/resolvers/exerciseSubmissionCrud.ts b/graphql/resolvers/exerciseSubmissionCrud.ts new file mode 100644 index 000000000..cd7fd1c5e --- /dev/null +++ b/graphql/resolvers/exerciseSubmissionCrud.ts @@ -0,0 +1,40 @@ +import { MutationAddExerciseSubmissionArgs } from '..' +import { Context } from '../../@types/helpers' +import prisma from '../../prisma' + +export const exerciseSubmissions = ( + _parent: void, + _args: void, + context: Context +) => { + const userId = context.req.user?.id + if (!userId) return [] + + return prisma.exerciseSubmission.findMany({ + where: { userId } + }) +} + +export const addExerciseSubmission = async ( + _parent: void, + { exerciseId, userAnswer }: MutationAddExerciseSubmissionArgs, + context: Context +) => { + const userId = context.req.user?.id + if (!userId) throw new Error('User should be logged in.') + + const exerciseSubmission = await prisma.exerciseSubmission.findFirst({ + where: { userId, exerciseId } + }) + + if (exerciseSubmission) { + return prisma.exerciseSubmission.update({ + data: { exerciseId, userId, userAnswer }, + where: { id: exerciseSubmission.id } + }) + } + + return prisma.exerciseSubmission.create({ + data: { exerciseId, userId, userAnswer } + }) +} diff --git a/graphql/typeDefs.ts b/graphql/typeDefs.ts index c02fb5d3e..1dfac77b2 100644 --- a/graphql/typeDefs.ts +++ b/graphql/typeDefs.ts @@ -13,6 +13,7 @@ export default gql` submissions(lessonId: Int!): [Submission!] alerts: [Alert!]! getPreviousSubmissions(challengeId: Int!, userId: Int!): [Submission!] + exerciseSubmissions: [ExerciseSubmission!]! } type TokenResponse { @@ -88,6 +89,10 @@ export default gql` flagExercise(id: Int!, flagReason: String!): Exercise removeExerciseFlag(id: Int!): Exercise! deleteExercise(id: Int!): Exercise! + addExerciseSubmission( + exerciseId: Int! + userAnswer: String! + ): ExerciseSubmission! createLesson( description: String! docUrl: String @@ -266,4 +271,11 @@ export default gql` flaggedBy: User flaggedById: Int } + + type ExerciseSubmission { + id: Int! + userId: Int! + exerciseId: Int! + userAnswer: String! + } ` diff --git a/pages/exercises/[lessonSlug].tsx b/pages/exercises/[lessonSlug].tsx index 038bff993..041d3e0ed 100644 --- a/pages/exercises/[lessonSlug].tsx +++ b/pages/exercises/[lessonSlug].tsx @@ -1,5 +1,5 @@ import { useRouter } from 'next/router' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import Layout from '../../components/Layout' import withQueryLoader, { QueryDataProps @@ -19,15 +19,25 @@ import styles from '../../scss/exercises.module.scss' const Exercises: React.FC> = ({ queryData }) => { - const { lessons, alerts, exercises } = queryData + const { lessons, alerts, exercises, exerciseSubmissions } = queryData const router = useRouter() const [exerciseIndex, setExerciseIndex] = useState(-1) const [userAnswers, setUserAnswers] = useState>({}) + useEffect(() => { + setUserAnswers( + Object.fromEntries( + exerciseSubmissions.map(submission => [ + submission.exerciseId, + submission.userAnswer + ]) + ) + ) + }, [exerciseSubmissions]) if (!router.isReady) return const slug = router.query.lessonSlug as string - if (!lessons || !alerts || !exercises) + if (!lessons || !alerts || !exercises || !exerciseSubmissions) return const currentLesson = lessons.find(lesson => lesson.slug === slug) diff --git a/prisma/migrations/20220929035259_add_exercise_submissions/migration.sql b/prisma/migrations/20220929035259_add_exercise_submissions/migration.sql new file mode 100644 index 000000000..4204dfbf8 --- /dev/null +++ b/prisma/migrations/20220929035259_add_exercise_submissions/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "exerciseSubmissions" ( + "id" SERIAL NOT NULL, + "exerciseId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "userAnswer" TEXT NOT NULL, + + CONSTRAINT "exerciseSubmissions_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "exerciseSubmissions" ADD CONSTRAINT "exerciseSubmissions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "exerciseSubmissions" ADD CONSTRAINT "exerciseSubmissions_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "exercises"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f0d69faf3..87103c790 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,33 +127,34 @@ model UserLesson { } model User { - id Int @id @default(autoincrement()) - name String @db.VarChar(255) - username String @db.VarChar(255) - password String? @db.VarChar(255) - email String @db.VarChar(255) + id Int @id @default(autoincrement()) + name String @db.VarChar(255) + username String @db.VarChar(255) + password String? @db.VarChar(255) + email String @db.VarChar(255) gsId Int? isOnline Boolean? - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) - isAdmin Boolean @default(false) - forgotToken String? @db.VarChar(255) - cliToken String? @db.VarChar(255) - emailVerificationToken String? @db.VarChar(255) - tokenExpiration DateTime? @db.Timestamptz(6) - discordRefreshToken String? @db.VarChar(255) - discordAccessToken String? @db.VarChar(255) - discordAccessTokenExpires DateTime? @db.Timestamptz(6) - discordId String? @db.VarChar(255) - cliVersion String? @db.VarChar(255) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + isAdmin Boolean @default(false) + forgotToken String? @db.VarChar(255) + cliToken String? @db.VarChar(255) + emailVerificationToken String? @db.VarChar(255) + tokenExpiration DateTime? @db.Timestamptz(6) + discordRefreshToken String? @db.VarChar(255) + discordAccessToken String? @db.VarChar(255) + discordAccessTokenExpires DateTime? @db.Timestamptz(6) + discordId String? @db.VarChar(255) + cliVersion String? @db.VarChar(255) comments Comment[] exercises Exercise[] - Exercise Exercise[] @relation("flaggedExercises") + exerciseSubmissions ExerciseSubmission[] + Exercise Exercise[] @relation("flaggedExercises") modules Module[] - starsMentor Star[] @relation("starMentor") - starsGiven Star[] @relation("starStudent") - submissionsReviewed Submission[] @relation("userReviewedSubmissions") - submissions Submission[] @relation("userSubmissions") + starsMentor Star[] @relation("starMentor") + starsGiven Star[] @relation("starStudent") + submissionsReviewed Submission[] @relation("userReviewedSubmissions") + submissions Submission[] @relation("userSubmissions") userLessons UserLesson[] @@map("users") @@ -176,9 +177,9 @@ model Module { } model Exercise { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) authorId Int moduleId Int description String @@ -188,9 +189,21 @@ model Exercise { flagReason String? flaggedAt DateTime? flaggedById Int? - author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - flaggedBy User? @relation("flaggedExercises", fields: [flaggedById], references: [id]) - module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + flaggedBy User? @relation("flaggedExercises", fields: [flaggedById], references: [id]) + module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) + submissions ExerciseSubmission[] @@map("exercises") } + +model ExerciseSubmission { + id Int @id @default(autoincrement()) + exerciseId Int + exercise Exercise @relation(fields: [exerciseId], references: [id], onDelete: Cascade) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userAnswer String + + @@map("exerciseSubmissions") +}