Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
-- CreateTable
CREATE TABLE "Quiz" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"description" TEXT,
"courseId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Quiz_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateTable
CREATE TABLE "Question" (
"id" TEXT NOT NULL PRIMARY KEY,
"quizId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"text" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Question_quizId_fkey" FOREIGN KEY ("quizId") REFERENCES "Quiz" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateTable
CREATE TABLE "Option" (
"id" TEXT NOT NULL PRIMARY KEY,
"questionId" TEXT NOT NULL,
"text" TEXT NOT NULL,
"isCorrect" BOOLEAN NOT NULL,
"order" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Option_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateTable
CREATE TABLE "QuizAttempt" (
"id" TEXT NOT NULL PRIMARY KEY,
"quizId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"score" REAL NOT NULL DEFAULT 0,
"totalPoints" REAL NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'in_progress',
"startedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "QuizAttempt_quizId_fkey" FOREIGN KEY ("quizId") REFERENCES "Quiz" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "QuizAttempt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateTable
CREATE TABLE "QuestionResponse" (
"id" TEXT NOT NULL PRIMARY KEY,
"attemptId" TEXT NOT NULL,
"questionId" TEXT NOT NULL,
"selectedOptionId" TEXT,
"isCorrect" BOOLEAN,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "QuestionResponse_attemptId_fkey" FOREIGN KEY ("attemptId") REFERENCES "QuizAttempt" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "QuestionResponse_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- CreateIndex
CREATE UNIQUE INDEX "QuizAttempt_quizId_userId_key" ON "QuizAttempt"("quizId", "userId");
68 changes: 68 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ model User {
enrollments Enrollment[]
courses Course[] @relation("AuthoredCourses")
videoProgress VideoProgress[]
quizAttempts QuizAttempt[]
}

model Course {
Expand All @@ -34,6 +35,7 @@ model Course {
lessons Lesson[]
enrollments Enrollment[]
videoProgress VideoProgress[]
quizzes Quiz[]
}

model Lesson {
Expand Down Expand Up @@ -78,3 +80,69 @@ model VideoProgress {

@@unique([userId, lessonId])
}

model Quiz {
id String @id @default(uuid())
title String
description String?
courseId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
questions Question[]
attempts QuizAttempt[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model Question {
id String @id @default(uuid())
quizId String
quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade)
type String // "multiple_choice" or "true_false"
text String
order Int
options Option[]
responses QuestionResponse[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model Option {
id String @id @default(uuid())
questionId String
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
text String
isCorrect Boolean
order Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model QuizAttempt {
id String @id @default(uuid())
quizId String
quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
score Float @default(0)
totalPoints Float @default(0)
status String @default("in_progress") // "in_progress", "completed"
startedAt DateTime @default(now())
completedAt DateTime?
responses QuestionResponse[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([quizId, userId])
}

model QuestionResponse {
id String @id @default(uuid())
attemptId String
attempt QuizAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade)
questionId String
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
selectedOptionId String?
isCorrect Boolean?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { LessonsModule } from './lessons/lessons.module';
import { EnrollmentsModule } from './enrollments/enrollments.module';
import { ProgressModule } from './progress/progress.module';
import { AdminModule } from './admin/admin.module';
import { QuizzesModule } from './quizzes/quizzes.module';
import { PrismaModule } from './prisma/prisma.module';

@Module({
Expand All @@ -24,6 +25,7 @@ import { PrismaModule } from './prisma/prisma.module';
EnrollmentsModule,
ProgressModule,
AdminModule,
QuizzesModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
60 changes: 60 additions & 0 deletions backend/src/quizzes/dto/create-quiz.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class CreateOptionDto {
@ApiProperty({ description: 'Option text' })
@IsNotEmpty()
@IsString()
text: string;

@ApiProperty({ description: 'Whether this option is correct' })
isCorrect: boolean;

@ApiProperty({ description: 'Option order' })
order: number;
}

export class CreateQuestionDto {
@ApiProperty({ description: 'Question type: multiple_choice or true_false' })
@IsNotEmpty()
@IsString()
type: string;

@ApiProperty({ description: 'Question text' })
@IsNotEmpty()
@IsString()
text: string;

@ApiProperty({ description: 'Question order' })
order: number;

@ApiProperty({ type: [CreateOptionDto], description: 'Question options' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateOptionDto)
options: CreateOptionDto[];
}

export class CreateQuizDto {
@ApiProperty({ description: 'Quiz title' })
@IsNotEmpty()
@IsString()
title: string;

@ApiPropertyOptional({ description: 'Quiz description' })
@IsOptional()
@IsString()
description?: string;

@ApiProperty({ description: 'Course ID' })
@IsNotEmpty()
@IsString()
courseId: string;

@ApiProperty({ type: [CreateQuestionDto], description: 'Quiz questions' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateQuestionDto)
questions: CreateQuestionDto[];
}
23 changes: 23 additions & 0 deletions backend/src/quizzes/dto/submit-quiz.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class QuestionAnswerDto {
@ApiProperty({ description: 'Question ID' })
@IsNotEmpty()
@IsString()
questionId: string;

@ApiProperty({ description: 'Selected option ID' })
@IsNotEmpty()
@IsString()
selectedOptionId: string;
}

export class SubmitQuizDto {
@ApiProperty({ type: [QuestionAnswerDto], description: 'Answers to questions' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => QuestionAnswerDto)
answers: QuestionAnswerDto[];
}
23 changes: 23 additions & 0 deletions backend/src/quizzes/dto/update-quiz.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { CreateQuestionDto } from './create-quiz.dto';

export class UpdateQuizDto {
@ApiPropertyOptional({ description: 'Quiz title' })
@IsOptional()
@IsString()
title?: string;

@ApiPropertyOptional({ description: 'Quiz description' })
@IsOptional()
@IsString()
description?: string;

@ApiPropertyOptional({ type: [CreateQuestionDto], description: 'Quiz questions' })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateQuestionDto)
questions?: CreateQuestionDto[];
}
99 changes: 99 additions & 0 deletions backend/src/quizzes/quizzes.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Request,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { QuizzesService } from './quizzes.service';
import { CreateQuizDto } from './dto/create-quiz.dto';
import { UpdateQuizDto } from './dto/update-quiz.dto';
import { SubmitQuizDto } from './dto/submit-quiz.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

@ApiTags('quizzes')
@Controller('quizzes')
export class QuizzesController {
constructor(private readonly quizzesService: QuizzesService) {}

@UseGuards(JwtAuthGuard)
@Post()
@ApiOperation({ summary: 'Create a new quiz (teacher only)' })
@ApiResponse({ status: 201, description: 'Quiz created successfully' })
create(@Body() createQuizDto: CreateQuizDto, @Request() req) {
return this.quizzesService.createQuiz(createQuizDto, req.user.id);
}

@UseGuards(JwtAuthGuard)
@Get('course/:courseId')
@ApiOperation({ summary: 'Get all quizzes for a course' })
getQuizzesByCourse(
@Param('courseId') courseId: string,
@Request() req,
) {
return this.quizzesService.getQuizzesByCourse(courseId, req.user.id, req.user.role);
}

@UseGuards(JwtAuthGuard)
@Get('attempts/my')
@ApiOperation({ summary: 'Get all quiz attempts for current user' })
getUserQuizAttempts(@Request() req) {
return this.quizzesService.getUserQuizAttempts(req.user.id);
}

@UseGuards(JwtAuthGuard)
@Get(':id')
@ApiOperation({ summary: 'Get quiz by ID' })
findOne(@Param('id') id: string, @Request() req) {
return this.quizzesService.getQuizById(id, req.user.id, req.user.role);
}

@UseGuards(JwtAuthGuard)
@Patch(':id')
@ApiOperation({ summary: 'Update a quiz (teacher only)' })
update(
@Param('id') id: string,
@Body() updateQuizDto: UpdateQuizDto,
@Request() req,
) {
return this.quizzesService.updateQuiz(id, updateQuizDto, req.user.id);
}

@UseGuards(JwtAuthGuard)
@Delete(':id')
@ApiOperation({ summary: 'Delete a quiz (teacher only)' })
remove(@Param('id') id: string, @Request() req) {
return this.quizzesService.deleteQuiz(id, req.user.id);
}

@UseGuards(JwtAuthGuard)
@Post(':id/start')
@ApiOperation({ summary: 'Start a quiz (student only)' })
startQuiz(@Param('id') id: string, @Request() req) {
return this.quizzesService.startQuiz(id, req.user.id);
}

@UseGuards(JwtAuthGuard)
@Post(':id/submit')
@ApiOperation({ summary: 'Submit quiz answers (student only)' })
submitQuiz(
@Param('id') id: string,
@Body() submitQuizDto: SubmitQuizDto,
@Request() req,
) {
return this.quizzesService.submitQuiz(id, req.user.id, submitQuizDto);
}

@UseGuards(JwtAuthGuard)
@Get(':id/result')
@ApiOperation({ summary: 'Get quiz result' })
getQuizResult(@Param('id') id: string, @Request() req) {
return this.quizzesService.getQuizResult(id, req.user.id);
}
}
12 changes: 12 additions & 0 deletions backend/src/quizzes/quizzes.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { QuizzesService } from './quizzes.service';
import { QuizzesController } from './quizzes.controller';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
imports: [PrismaModule],
controllers: [QuizzesController],
providers: [QuizzesService],
exports: [QuizzesService],
})
export class QuizzesModule {}
Loading