eduverse-backend / src /modules /quizzes /controllers /quizzes.controller.ts
tarekdx3's picture
feat(quizzes): add auto-save for quiz progress and enrich quiz attempt data
eac689e
Raw
History Blame Contribute Delete
30.9 kB
import {
Controller,
Get,
Post,
Put,
Delete,
Patch,
Body,
Param,
Query,
ParseIntPipe,
UseGuards,
Req,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
ApiBody,
ApiHeader,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { Roles } from '../../auth/roles.decorator';
import { RoleName } from '../../auth/entities/role.entity';
import { QuizzesService } from '../services/quizzes.service';
import { QuizGradingService } from '../services/quiz-grading.service';
import {
CreateQuizDto,
UpdateQuizDto,
CreateQuestionDto,
UpdateQuestionDto,
StartAttemptDto,
SubmitQuizDto,
QuizQueryDto,
AttemptQueryDto,
ManualGradeDto,
AttemptResultDto,
QuizStatisticsDto,
StudentQuizProgressDto,
ReorderQuestionsDto,
} from '../dto';
import { Quiz, QuizQuestion, QuizAttempt, QuizDifficultyLevel } from '../entities';
import { QuizStatus } from '../enums';
@ApiTags('📝 Quizzes')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('api/quizzes')
@ApiHeader({
name: 'Accept-Language',
description: 'Language preference (en, ar)',
required: false,
example: 'en',
})
export class QuizzesController {
constructor(
private readonly quizzesService: QuizzesService,
private readonly gradingService: QuizGradingService,
) {}
private getRoleNames(user: any): string[] {
if (!Array.isArray(user?.roles)) return [];
return user.roles.map((role: any) =>
typeof role === 'string' ? role : role.roleName || role.name,
);
}
// ============ QUIZ CRUD ============
@Post()
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Create a new quiz',
description: `
## Create Quiz
Creates a new quiz for a course with configurable settings.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Required Fields
- \`courseId\`: Course the quiz belongs to
- \`title\`: Quiz title
### Optional Fields
- \`description\`: Quiz description/instructions
- \`quizType\`: Type (practice, graded, midterm, final)
- \`timeLimitMinutes\`: Time limit for completion
- \`maxAttempts\`: Maximum allowed attempts (null = unlimited)
- \`passingScore\`: Minimum percentage to pass
- \`randomizeQuestions\`: Shuffle question order per attempt
- \`showCorrectAnswers\`: When to reveal answers
- \`availableFrom\` / \`availableUntil\`: Availability window
- \`weight\`: Grade weight in the course
### Behavior
- Quiz is created in \`draft\` status
- Add questions before publishing
- Students cannot see draft quizzes
`,
})
@ApiBody({ type: CreateQuizDto })
@ApiResponse({ status: 201, description: 'Quiz created successfully', type: Quiz })
@ApiResponse({ status: 400, description: 'Invalid input or course not found' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
async createQuiz(@Body() dto: CreateQuizDto, @Req() req: any): Promise<Quiz> {
return this.quizzesService.createQuiz(
dto,
req.user.userId,
this.getRoleNames(req.user),
);
}
@Get()
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.STUDENT)
@ApiOperation({
summary: 'Get all quizzes with filters',
description: `
## List All Quizzes
Retrieves a paginated list of quizzes with optional filters.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN, STUDENT
### Role-Based Visibility
- **Instructors/TAs/Admins**: See all quizzes including drafts
- **Students**: See only published quizzes within availability window
### Filtering Options
- \`courseId\`: Filter by specific course
- \`quizType\`: Filter by type (practice, graded, midterm, final)
- \`availableOnly\`: Show only currently available quizzes
- \`search\`: Search by quiz title or description
### Pagination
- \`page\`: Page number (default: 1)
- \`limit\`: Items per page (default: 20, max: 100)
### Response
Returns \`data\` array with quizzes and \`total\` count for pagination.
`,
})
@ApiQuery({ name: 'courseId', required: false, type: Number, description: 'Filter by course ID' })
@ApiQuery({ name: 'quizType', required: false, enum: ['practice', 'graded', 'midterm', 'final'] })
@ApiQuery({ name: 'availableOnly', required: false, type: Boolean })
@ApiQuery({ name: 'search', required: false, type: String })
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, example: 20 })
@ApiResponse({ status: 200, description: 'List of quizzes returned' })
async findAllQuizzes(@Query() query: QuizQueryDto): Promise<{ data: Quiz[]; total: number }> {
return this.quizzesService.findAllQuizzes(query);
}
@Get('difficulty-levels')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Get available difficulty levels',
description: `
## Get Difficulty Levels
Returns all available difficulty levels for quiz questions.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Response Includes
Array of difficulty levels with:
- \`difficultyId\`: Unique identifier
- \`levelName\`: Name (Easy, Medium, Hard, etc.)
- \`difficultyValue\`: Numeric value for sorting/filtering
- \`description\`: Description of the difficulty level
### Use Cases
- Populate dropdown when creating questions
- Filter questions by difficulty for quiz creation
`,
})
@ApiResponse({ status: 200, description: 'Difficulty levels returned', type: [QuizDifficultyLevel] })
async getDifficultyLevels(): Promise<QuizDifficultyLevel[]> {
return this.quizzesService.getDifficultyLevels();
}
@Get('my-attempts')
@Roles(RoleName.STUDENT)
@ApiOperation({
summary: "Get current student's quiz attempts",
description: `
## My Quiz Attempts
Returns all quiz attempts for the authenticated student.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: STUDENT
### Filtering Options
- \`quizId\`: Filter by specific quiz
- \`status\`: Filter by status (in_progress, submitted, graded, abandoned)
### Pagination
- \`page\`: Page number (default: 1)
- \`limit\`: Items per page (default: 20)
### Response Includes
For each attempt:
- Quiz information
- Start/submit timestamps
- Score and status
- Time taken
### Use Cases
- View attempt history
- Resume in-progress attempts
- Review past performance
`,
})
@ApiQuery({ name: 'quizId', required: false, type: Number })
@ApiQuery({ name: 'status', required: false, enum: ['in_progress', 'submitted', 'graded', 'abandoned'] })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiResponse({ status: 200, description: 'List of student attempts' })
async getMyAttempts(@Query() query: AttemptQueryDto, @Req() req: any): Promise<{ data: QuizAttempt[]; total: number }> {
return this.quizzesService.findAttemptsByQuery({ ...query, userId: req.user.userId });
}
@Get('attempts')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'List all quiz attempts (instructor view)',
description: `
## List Quiz Attempts
Retrieves a paginated list of quiz attempts with optional filters. For instructor/TA grading and review.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Filtering Options
- \`quizId\`: Filter by specific quiz
- \`userId\`: Filter by specific student
- \`status\`: Filter by attempt status (in_progress, submitted, graded, abandoned)
- \`startDate\` / \`endDate\`: Filter by date range
### Pagination
- \`page\`: Page number (default: 1)
- \`limit\`: Items per page (default: 20, max: 100)
### Response Includes
For each attempt:
- Student information
- Quiz information
- Start/submit timestamps
- Score and status
- Grading status
`,
})
@ApiQuery({ name: 'quizId', required: false, type: Number })
@ApiQuery({ name: 'userId', required: false, type: Number })
@ApiQuery({ name: 'status', required: false, enum: ['in_progress', 'submitted', 'graded', 'abandoned'] })
@ApiQuery({ name: 'startDate', required: false, type: String, example: '2026-01-01' })
@ApiQuery({ name: 'endDate', required: false, type: String, example: '2026-03-31' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiResponse({ status: 200, description: 'List of attempts' })
async findAttempts(@Query() query: AttemptQueryDto): Promise<{ data: QuizAttempt[]; total: number }> {
return this.quizzesService.findAttemptsByQuery(query);
}
@Get(':id')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.STUDENT)
@ApiOperation({
summary: 'Get quiz by ID with questions',
description: `
## Get Quiz Details
Retrieves detailed information about a specific quiz including its questions.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN, STUDENT
### Role-Based Response
- **Instructors/TAs/Admins**: Full details including correct answers
- **Students**: Questions without correct answers (unless allowed by quiz settings)
### Response Includes
- Quiz metadata (title, description, settings)
- Course information
- Time limit, attempts allowed, passing score
- All questions with their options
- Availability window
`,
})
@ApiParam({ name: 'id', description: 'Quiz ID', type: Number })
@ApiResponse({ status: 200, description: 'Quiz found', type: Quiz })
@ApiResponse({ status: 404, description: 'Quiz not found' })
async findQuizById(@Param('id', ParseIntPipe) id: number): Promise<Quiz> {
return this.quizzesService.findQuizById(id);
}
@Put(':id')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Update quiz settings',
description: `
## Update Quiz
Updates quiz settings and metadata. Does not modify individual questions.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Updatable Fields
All fields from CreateQuizDto can be updated:
- Title, description, instructions
- Time limit, max attempts, passing score
- Randomization settings
- Availability window
- Answer visibility settings
### Important Notes
- Cannot update a quiz that has active attempts
- Changing time limit won't affect in-progress attempts
- Use separate endpoints for question management
`,
})
@ApiParam({ name: 'id', description: 'Quiz ID', type: Number })
@ApiBody({ type: UpdateQuizDto })
@ApiResponse({ status: 200, description: 'Quiz updated', type: Quiz })
@ApiResponse({ status: 404, description: 'Quiz not found' })
async updateQuiz(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateQuizDto,
@Req() req: any,
): Promise<Quiz> {
return this.quizzesService.updateQuiz(
id,
dto,
req.user.userId,
this.getRoleNames(req.user),
);
}
@Delete(':id')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Delete quiz (soft delete)',
description: `
## Delete Quiz
Soft deletes a quiz. The quiz data is preserved but hidden from users.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, ADMIN
- TAs cannot delete quizzes
### Behavior
- Sets \`deleted_at\` timestamp (soft delete)
- Quiz no longer appears in listings
- Existing attempts and grades are preserved
- Cannot be undone via API (requires database intervention)
### When to Use
- Quiz created by mistake
- Obsolete quiz no longer needed
- Do NOT use for temporarily hiding a quiz (use status instead)
`,
})
@ApiParam({ name: 'id', description: 'Quiz ID', type: Number })
@ApiResponse({ status: 200, description: 'Quiz deleted' })
@ApiResponse({ status: 404, description: 'Quiz not found' })
async deleteQuiz(
@Param('id', ParseIntPipe) id: number,
@Req() req: any,
): Promise<{ message: string }> {
await this.quizzesService.deleteQuiz(
id,
req.user.userId,
this.getRoleNames(req.user),
);
return { message: 'Quiz deleted successfully' };
}
@Patch(':id/status')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Change quiz status',
description: `
## Change Quiz Status
Updates the status of a quiz (publish, close, or archive).
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Status Values
- \`draft\`: Not visible to students
- \`published\`: Available for students to take
- \`closed\`: No longer accepting attempts
- \`archived\`: Hidden from all views
### Behavior
- Students can only see published quizzes
- Closed quizzes preserve existing attempts but prevent new ones
- Archived quizzes are soft-hidden
`,
})
@ApiParam({ name: 'id', description: 'Quiz ID', type: Number })
@ApiBody({
schema: {
properties: {
status: {
type: 'string',
enum: ['draft', 'published', 'closed', 'archived'],
example: 'published',
},
},
},
})
@ApiResponse({ status: 200, description: 'Quiz status updated', type: Quiz })
@ApiResponse({ status: 404, description: 'Quiz not found' })
async changeStatus(
@Param('id', ParseIntPipe) id: number,
@Body('status') status: QuizStatus,
@Req() req: any,
): Promise<Quiz> {
return this.quizzesService.changeStatus(
id,
status,
req.user.userId,
this.getRoleNames(req.user),
);
}
// ============ QUESTION MANAGEMENT ============
@Post(':quizId/questions')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Add question to quiz',
description: `
## Add Question
Adds a new question to an existing quiz.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Required Fields
- \`questionText\`: The question content
- \`questionType\`: Type (mcq, true_false, short_answer, essay, matching)
- \`points\`: Points awarded for correct answer
### Question Type Specific Fields
**MCQ (Multiple Choice)**:
- \`options\`: Array of answer options
- \`correctAnswer\`: Index of correct option (0-based)
**True/False**:
- \`correctAnswer\`: "true" or "false"
**Short Answer**:
- \`correctAnswer\`: Expected answer text (for auto-grading)
**Essay**:
- No correct answer (requires manual grading)
**Matching**:
- \`options\`: { left: [...], right: [...] }
- \`correctAnswer\`: Mapping of left to right indices
### Optional Fields
- \`explanation\`: Explanation shown after quiz
- \`difficultyLevelId\`: Reference to difficulty level
- \`orderIndex\`: Position in quiz (auto-assigned if not provided)
`,
})
@ApiParam({ name: 'quizId', description: 'Quiz ID', type: Number })
@ApiBody({ type: CreateQuestionDto })
@ApiResponse({ status: 201, description: 'Question added', type: QuizQuestion })
@ApiResponse({ status: 404, description: 'Quiz not found' })
async addQuestion(
@Param('quizId', ParseIntPipe) quizId: number,
@Body() dto: CreateQuestionDto,
@Req() req: any,
): Promise<QuizQuestion> {
return this.quizzesService.addQuestion(
quizId,
dto,
req.user.userId,
this.getRoleNames(req.user),
);
}
@Put(':quizId/questions/reorder')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Reorder quiz questions',
description: `
## Reorder Questions
Sets the display order for all questions in a quiz.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Request Body
\`\`\`json
{
"questionIds": [5, 3, 1, 4, 2]
}
\`\`\`
Array of question IDs in desired order.
### Behavior
- All question IDs must belong to the specified quiz
- Order index is assigned based on array position
- Missing question IDs will retain their current position
### Note
If \`randomizeQuestions\` is enabled for the quiz, this order only affects the instructor view.
`,
})
@ApiParam({ name: 'quizId', description: 'Quiz ID', type: Number })
@ApiBody({ type: ReorderQuestionsDto })
@ApiResponse({ status: 200, description: 'Questions reordered', type: [QuizQuestion] })
async reorderQuestions(
@Param('quizId', ParseIntPipe) quizId: number,
@Body() dto: ReorderQuestionsDto,
): Promise<QuizQuestion[]> {
return this.quizzesService.reorderQuestions(quizId, dto.questionIds);
}
@Put(':quizId/questions/:questionId')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Update quiz question',
description: `
## Update Question
Updates an existing question in a quiz.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Updatable Fields
All CreateQuestionDto fields can be updated:
- Question text
- Options and correct answer
- Points
- Explanation
- Difficulty level
### Important Notes
- Changing a question after students have attempted may affect grade consistency
- Consider creating a new question instead if quiz has been taken
`,
})
@ApiParam({ name: 'quizId', description: 'Quiz ID', type: Number })
@ApiParam({ name: 'questionId', description: 'Question ID', type: Number })
@ApiBody({ type: UpdateQuestionDto })
@ApiResponse({ status: 200, description: 'Question updated', type: QuizQuestion })
@ApiResponse({ status: 404, description: 'Question not found' })
async updateQuestion(
@Param('quizId', ParseIntPipe) quizId: number,
@Param('questionId', ParseIntPipe) questionId: number,
@Body() dto: UpdateQuestionDto,
@Req() req: any,
): Promise<QuizQuestion> {
return this.quizzesService.updateQuestion(
quizId,
questionId,
dto,
req.user.userId,
this.getRoleNames(req.user),
);
}
@Delete(':quizId/questions/:questionId')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Delete question from quiz',
description: `
## Delete Question
Removes a question from a quiz.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Behavior
- Question is permanently deleted
- Remaining questions maintain their order
- Existing answers referencing this question are orphaned
### Caution
- Do not delete questions from quizzes that have been attempted
- This may invalidate existing scores
`,
})
@ApiParam({ name: 'quizId', description: 'Quiz ID', type: Number })
@ApiParam({ name: 'questionId', description: 'Question ID', type: Number })
@ApiResponse({ status: 200, description: 'Question deleted' })
@ApiResponse({ status: 404, description: 'Question not found' })
async deleteQuestion(
@Param('quizId', ParseIntPipe) quizId: number,
@Param('questionId', ParseIntPipe) questionId: number,
@Req() req: any,
): Promise<{ message: string }> {
await this.quizzesService.deleteQuestion(
quizId,
questionId,
req.user.userId,
this.getRoleNames(req.user),
);
return { message: 'Question deleted successfully' };
}
// ============ QUIZ ATTEMPTS ============
@Post(':quizId/attempts/start')
@Roles(RoleName.STUDENT)
@ApiOperation({
summary: 'Start a quiz attempt',
description: `
## Start Quiz Attempt
Starts a new quiz attempt for the authenticated student.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: STUDENT
### Prerequisites
- Quiz must be published and within availability window
- Student must not have exceeded \`maxAttempts\`
- No in-progress attempt exists for this quiz
### Behavior
- Creates new attempt record with \`in_progress\` status
- Records start time for time limit enforcement
- Logs student IP address
- If \`randomizeQuestions\` is enabled, questions are shuffled
### Response Includes
- Attempt ID (needed for submission)
- Quiz questions (without correct answers)
- Time remaining (if time limited)
- Start timestamp
### Important
- Timer starts immediately upon calling this endpoint
- Time limit is enforced on submission
`,
})
@ApiParam({ name: 'quizId', description: 'Quiz ID', type: Number })
@ApiBody({ type: StartAttemptDto })
@ApiResponse({ status: 201, description: 'Attempt started', type: QuizAttempt })
@ApiResponse({ status: 400, description: 'Quiz not available or attempt limit reached' })
async startAttempt(
@Param('quizId', ParseIntPipe) quizId: number,
@Body() dto: StartAttemptDto,
@Req() req: any,
): Promise<QuizAttempt> {
return this.quizzesService.startAttempt(quizId, req.user.userId, dto);
}
@Patch(':quizId/attempts/:attemptId/progress')
@Roles(RoleName.STUDENT)
@ApiOperation({
summary: 'Auto-save quiz progress',
description: `
## Save Quiz Progress
Saves in-progress answers without submitting the attempt.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: STUDENT
### Behavior
- Updates existing answers for the attempt
- Inserts new answers if they do not exist yet
- Keeps attempt in \`in_progress\` status
`,
})
@ApiParam({ name: 'quizId', description: 'Quiz ID', type: Number })
@ApiParam({ name: 'attemptId', description: 'Attempt ID', type: Number })
@ApiBody({ type: SubmitQuizDto })
@ApiResponse({ status: 200, description: 'Progress saved', type: QuizAttempt })
@ApiResponse({ status: 400, description: 'Attempt is not in progress' })
@ApiResponse({ status: 404, description: 'Attempt not found' })
async saveAttemptProgress(
@Param('quizId', ParseIntPipe) quizId: number,
@Param('attemptId', ParseIntPipe) attemptId: number,
@Body() dto: SubmitQuizDto,
@Req() req: any,
): Promise<QuizAttempt> {
return this.quizzesService.saveAttemptProgress(quizId, attemptId, req.user.userId, dto);
}
@Post('attempts/:attemptId/submit')
@Roles(RoleName.STUDENT)
@ApiOperation({
summary: 'Submit quiz answers',
description: `
## Submit Quiz
Submits answers and completes the quiz attempt.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: STUDENT
### Request Body
\`\`\`json
{
"answers": [
{ "questionId": 1, "selectedOption": 2 },
{ "questionId": 2, "answerText": "true" },
{ "questionId": 3, "answerText": "Essay response here..." }
]
}
\`\`\`
### Validation
- Attempt must be \`in_progress\` status
- Cannot submit after time limit expires
- Student must own the attempt
### Auto-Grading
- **MCQ**: Immediately graded
- **True/False**: Immediately graded
- **Short Answer**: Auto-graded if exact match, otherwise manual
- **Essay**: Always requires manual grading
### Response Includes
- Final score (for auto-graded questions)
- Per-question results (if \`showCorrectAnswers\` allows)
- Passing status
- Time taken
### Late Submissions
If submitted after time limit, the attempt is marked but may be flagged for review.
`,
})
@ApiParam({ name: 'attemptId', description: 'Attempt ID', type: Number })
@ApiBody({ type: SubmitQuizDto })
@ApiResponse({ status: 200, description: 'Quiz submitted, returns results', type: AttemptResultDto })
@ApiResponse({ status: 400, description: 'Time expired or already submitted' })
@ApiResponse({ status: 404, description: 'Attempt not found' })
async submitAttempt(
@Param('attemptId', ParseIntPipe) attemptId: number,
@Body() dto: SubmitQuizDto,
@Req() req: any,
): Promise<AttemptResultDto> {
return this.quizzesService.submitAttempt(attemptId, req.user.userId, dto);
}
@Get('attempts/:attemptId')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.STUDENT)
@ApiOperation({
summary: 'Get attempt result details',
description: `
## Get Attempt Result
Retrieves detailed results for a specific quiz attempt.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN, STUDENT
### Authorization
- Students can only view their own attempts
- Instructors/TAs/Admins can view any attempt
### Response Includes
- Attempt metadata (started, submitted, time taken)
- Score and passing status
- Per-question breakdown:
- Student's answer
- Correct answer (based on \`showCorrectAnswers\` setting)
- Points earned
- Explanation (if provided)
### Answer Visibility Rules
Controlled by quiz \`showCorrectAnswers\` setting:
- \`immediate\`: Show right after submission
- \`after_due\`: Show after \`availableUntil\` date
- \`never\`: Never show correct answers
`,
})
@ApiParam({ name: 'attemptId', description: 'Attempt ID', type: Number })
@ApiResponse({ status: 200, description: 'Attempt result', type: AttemptResultDto })
@ApiResponse({ status: 404, description: 'Attempt not found' })
async getAttemptResult(
@Param('attemptId', ParseIntPipe) attemptId: number,
@Req() req: any,
): Promise<AttemptResultDto> {
return this.quizzesService.getAttemptResult(attemptId, req.user.userId);
}
// ============ GRADING ============
@Post('attempts/:attemptId/grade')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Apply manual grades to attempt',
description: `
## Manual Grading
Apply manual grades to essay and short answer questions that require human evaluation.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Request Body
\`\`\`json
{
"grades": [
{ "answerId": 1, "pointsEarned": 8, "feedback": "Good explanation but missing key concept" },
{ "answerId": 2, "pointsEarned": 10, "feedback": "Excellent response!" }
]
}
\`\`\`
### Behavior
- Updates points earned for specified answers
- Recalculates total attempt score
- Changes attempt status to \`graded\` when all questions are graded
- Optionally adds feedback visible to student
### Validation
- Points cannot exceed question's max points
- Points cannot be negative
- Answer must belong to the specified attempt
`,
})
@ApiParam({ name: 'attemptId', description: 'Attempt ID', type: Number })
@ApiBody({ type: ManualGradeDto })
@ApiResponse({ status: 200, description: 'Grades applied', type: AttemptResultDto })
@ApiResponse({ status: 404, description: 'Attempt not found' })
async applyManualGrades(
@Param('attemptId', ParseIntPipe) attemptId: number,
@Body() dto: ManualGradeDto,
@Req() req: any,
): Promise<AttemptResultDto> {
const graderId = req.user.userId;
return this.gradingService.applyManualGrades(attemptId, dto, graderId);
}
@Get('attempts/:attemptId/pending-grading')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Get questions needing manual grading',
description: `
## Get Pending Grading
Returns essay and short answer questions that require manual grading for a specific attempt.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Response Includes
Array of answers needing grading:
- Question text
- Student's answer
- Max points for question
- Current points (0 if ungraded)
- Question type
### Use Cases
- Build grading queue for instructors
- Show count of pending grades on dashboard
- Filter to find specific types of questions
`,
})
@ApiParam({ name: 'attemptId', description: 'Attempt ID', type: Number })
@ApiResponse({ status: 200, description: 'Questions needing grading' })
async getQuestionsNeedingGrading(@Param('attemptId', ParseIntPipe) attemptId: number) {
return this.gradingService.getQuestionsNeedingGrading(attemptId);
}
// ============ STATISTICS & PROGRESS ============
@Get(':quizId/statistics')
@Roles(RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN)
@ApiOperation({
summary: 'Get quiz analytics and statistics',
description: `
## Quiz Statistics
Returns comprehensive analytics and statistics for a quiz.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: INSTRUCTOR, TA, ADMIN
### Response Includes
**Overall Statistics**:
- Total attempts
- Unique students attempted
- Average score
- Highest/Lowest scores
- Pass rate percentage
- Average time to complete
**Score Distribution**:
- Histogram of score ranges
- Median score
**Per-Question Analytics**:
- Correct answer percentage
- Most common wrong answers
- Average points earned
- Difficulty correlation
### Use Cases
- Identify challenging questions
- Assess quiz difficulty
- Monitor class performance
- Inform curriculum adjustments
`,
})
@ApiParam({ name: 'quizId', description: 'Quiz ID', type: Number })
@ApiResponse({ status: 200, description: 'Quiz statistics', type: QuizStatisticsDto })
@ApiResponse({ status: 404, description: 'Quiz not found' })
async getQuizStatistics(@Param('quizId', ParseIntPipe) quizId: number): Promise<QuizStatisticsDto> {
return this.quizzesService.getQuizStatistics(quizId);
}
@Get('progress/course/:courseId')
@Roles(RoleName.STUDENT)
@ApiOperation({
summary: "Get student's quiz progress for a course",
description: `
## Student Quiz Progress
Returns the authenticated student's progress across all quizzes in a course.
### Access Control
- **Authentication Required**: ✅ Yes (Bearer Token)
- **Roles Required**: STUDENT
### Response Includes
For each quiz in the course:
- Quiz title and type
- Completion status
- Best score achieved
- Attempts used vs allowed
- Upcoming due dates
- Pass/fail status
**Summary**:
- Total quizzes in course
- Quizzes completed
- Average score
- Overall quiz grade contribution
### Use Cases
- Student dashboard
- Progress tracking
- Identifying pending quizzes
`,
})
@ApiParam({ name: 'courseId', description: 'Course ID', type: Number })
@ApiResponse({ status: 200, description: 'Student progress', type: StudentQuizProgressDto })
async getStudentProgress(
@Param('courseId', ParseIntPipe) courseId: number,
@Req() req: any,
): Promise<StudentQuizProgressDto> {
return this.quizzesService.getStudentProgress(req.user.userId, courseId);
}
}