Spaces:
Sleeping
Sleeping
| 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'; | |
| ('📝 Quizzes') | |
| ('JWT-auth') | |
| (JwtAuthGuard, RolesGuard) | |
| ('api/quizzes') | |
| ({ | |
| 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 ============ | |
| () | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ type: CreateQuizDto }) | |
| ({ status: 201, description: 'Quiz created successfully', type: Quiz }) | |
| ({ status: 400, description: 'Invalid input or course not found' }) | |
| ({ status: 401, description: 'Unauthorized' }) | |
| ({ status: 403, description: 'Forbidden - insufficient permissions' }) | |
| async createQuiz(() dto: CreateQuizDto, () req: any): Promise<Quiz> { | |
| return this.quizzesService.createQuiz( | |
| dto, | |
| req.user.userId, | |
| this.getRoleNames(req.user), | |
| ); | |
| } | |
| () | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.STUDENT) | |
| ({ | |
| 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. | |
| `, | |
| }) | |
| ({ name: 'courseId', required: false, type: Number, description: 'Filter by course ID' }) | |
| ({ name: 'quizType', required: false, enum: ['practice', 'graded', 'midterm', 'final'] }) | |
| ({ name: 'availableOnly', required: false, type: Boolean }) | |
| ({ name: 'search', required: false, type: String }) | |
| ({ name: 'page', required: false, type: Number, example: 1 }) | |
| ({ name: 'limit', required: false, type: Number, example: 20 }) | |
| ({ status: 200, description: 'List of quizzes returned' }) | |
| async findAllQuizzes(() query: QuizQueryDto): Promise<{ data: Quiz[]; total: number }> { | |
| return this.quizzesService.findAllQuizzes(query); | |
| } | |
| ('difficulty-levels') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ status: 200, description: 'Difficulty levels returned', type: [QuizDifficultyLevel] }) | |
| async getDifficultyLevels(): Promise<QuizDifficultyLevel[]> { | |
| return this.quizzesService.getDifficultyLevels(); | |
| } | |
| ('my-attempts') | |
| (RoleName.STUDENT) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'quizId', required: false, type: Number }) | |
| ({ name: 'status', required: false, enum: ['in_progress', 'submitted', 'graded', 'abandoned'] }) | |
| ({ name: 'page', required: false, type: Number }) | |
| ({ name: 'limit', required: false, type: Number }) | |
| ({ status: 200, description: 'List of student attempts' }) | |
| async getMyAttempts(() query: AttemptQueryDto, () req: any): Promise<{ data: QuizAttempt[]; total: number }> { | |
| return this.quizzesService.findAttemptsByQuery({ ...query, userId: req.user.userId }); | |
| } | |
| ('attempts') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'quizId', required: false, type: Number }) | |
| ({ name: 'userId', required: false, type: Number }) | |
| ({ name: 'status', required: false, enum: ['in_progress', 'submitted', 'graded', 'abandoned'] }) | |
| ({ name: 'startDate', required: false, type: String, example: '2026-01-01' }) | |
| ({ name: 'endDate', required: false, type: String, example: '2026-03-31' }) | |
| ({ name: 'page', required: false, type: Number }) | |
| ({ name: 'limit', required: false, type: Number }) | |
| ({ status: 200, description: 'List of attempts' }) | |
| async findAttempts(() query: AttemptQueryDto): Promise<{ data: QuizAttempt[]; total: number }> { | |
| return this.quizzesService.findAttemptsByQuery(query); | |
| } | |
| (':id') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.STUDENT) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'id', description: 'Quiz ID', type: Number }) | |
| ({ status: 200, description: 'Quiz found', type: Quiz }) | |
| ({ status: 404, description: 'Quiz not found' }) | |
| async findQuizById(('id', ParseIntPipe) id: number): Promise<Quiz> { | |
| return this.quizzesService.findQuizById(id); | |
| } | |
| (':id') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'id', description: 'Quiz ID', type: Number }) | |
| ({ type: UpdateQuizDto }) | |
| ({ status: 200, description: 'Quiz updated', type: Quiz }) | |
| ({ status: 404, description: 'Quiz not found' }) | |
| async updateQuiz( | |
| ('id', ParseIntPipe) id: number, | |
| () dto: UpdateQuizDto, | |
| () req: any, | |
| ): Promise<Quiz> { | |
| return this.quizzesService.updateQuiz( | |
| id, | |
| dto, | |
| req.user.userId, | |
| this.getRoleNames(req.user), | |
| ); | |
| } | |
| (':id') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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) | |
| `, | |
| }) | |
| ({ name: 'id', description: 'Quiz ID', type: Number }) | |
| ({ status: 200, description: 'Quiz deleted' }) | |
| ({ status: 404, description: 'Quiz not found' }) | |
| async deleteQuiz( | |
| ('id', ParseIntPipe) id: number, | |
| () req: any, | |
| ): Promise<{ message: string }> { | |
| await this.quizzesService.deleteQuiz( | |
| id, | |
| req.user.userId, | |
| this.getRoleNames(req.user), | |
| ); | |
| return { message: 'Quiz deleted successfully' }; | |
| } | |
| (':id/status') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'id', description: 'Quiz ID', type: Number }) | |
| ({ | |
| schema: { | |
| properties: { | |
| status: { | |
| type: 'string', | |
| enum: ['draft', 'published', 'closed', 'archived'], | |
| example: 'published', | |
| }, | |
| }, | |
| }, | |
| }) | |
| ({ status: 200, description: 'Quiz status updated', type: Quiz }) | |
| ({ status: 404, description: 'Quiz not found' }) | |
| async changeStatus( | |
| ('id', ParseIntPipe) id: number, | |
| ('status') status: QuizStatus, | |
| () req: any, | |
| ): Promise<Quiz> { | |
| return this.quizzesService.changeStatus( | |
| id, | |
| status, | |
| req.user.userId, | |
| this.getRoleNames(req.user), | |
| ); | |
| } | |
| // ============ QUESTION MANAGEMENT ============ | |
| (':quizId/questions') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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) | |
| `, | |
| }) | |
| ({ name: 'quizId', description: 'Quiz ID', type: Number }) | |
| ({ type: CreateQuestionDto }) | |
| ({ status: 201, description: 'Question added', type: QuizQuestion }) | |
| ({ status: 404, description: 'Quiz not found' }) | |
| async addQuestion( | |
| ('quizId', ParseIntPipe) quizId: number, | |
| () dto: CreateQuestionDto, | |
| () req: any, | |
| ): Promise<QuizQuestion> { | |
| return this.quizzesService.addQuestion( | |
| quizId, | |
| dto, | |
| req.user.userId, | |
| this.getRoleNames(req.user), | |
| ); | |
| } | |
| (':quizId/questions/reorder') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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. | |
| `, | |
| }) | |
| ({ name: 'quizId', description: 'Quiz ID', type: Number }) | |
| ({ type: ReorderQuestionsDto }) | |
| ({ status: 200, description: 'Questions reordered', type: [QuizQuestion] }) | |
| async reorderQuestions( | |
| ('quizId', ParseIntPipe) quizId: number, | |
| () dto: ReorderQuestionsDto, | |
| ): Promise<QuizQuestion[]> { | |
| return this.quizzesService.reorderQuestions(quizId, dto.questionIds); | |
| } | |
| (':quizId/questions/:questionId') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'quizId', description: 'Quiz ID', type: Number }) | |
| ({ name: 'questionId', description: 'Question ID', type: Number }) | |
| ({ type: UpdateQuestionDto }) | |
| ({ status: 200, description: 'Question updated', type: QuizQuestion }) | |
| ({ status: 404, description: 'Question not found' }) | |
| async updateQuestion( | |
| ('quizId', ParseIntPipe) quizId: number, | |
| ('questionId', ParseIntPipe) questionId: number, | |
| () dto: UpdateQuestionDto, | |
| () req: any, | |
| ): Promise<QuizQuestion> { | |
| return this.quizzesService.updateQuestion( | |
| quizId, | |
| questionId, | |
| dto, | |
| req.user.userId, | |
| this.getRoleNames(req.user), | |
| ); | |
| } | |
| (':quizId/questions/:questionId') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'quizId', description: 'Quiz ID', type: Number }) | |
| ({ name: 'questionId', description: 'Question ID', type: Number }) | |
| ({ status: 200, description: 'Question deleted' }) | |
| ({ status: 404, description: 'Question not found' }) | |
| async deleteQuestion( | |
| ('quizId', ParseIntPipe) quizId: number, | |
| ('questionId', ParseIntPipe) questionId: number, | |
| () 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 ============ | |
| (':quizId/attempts/start') | |
| (RoleName.STUDENT) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'quizId', description: 'Quiz ID', type: Number }) | |
| ({ type: StartAttemptDto }) | |
| ({ status: 201, description: 'Attempt started', type: QuizAttempt }) | |
| ({ status: 400, description: 'Quiz not available or attempt limit reached' }) | |
| async startAttempt( | |
| ('quizId', ParseIntPipe) quizId: number, | |
| () dto: StartAttemptDto, | |
| () req: any, | |
| ): Promise<QuizAttempt> { | |
| return this.quizzesService.startAttempt(quizId, req.user.userId, dto); | |
| } | |
| (':quizId/attempts/:attemptId/progress') | |
| (RoleName.STUDENT) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'quizId', description: 'Quiz ID', type: Number }) | |
| ({ name: 'attemptId', description: 'Attempt ID', type: Number }) | |
| ({ type: SubmitQuizDto }) | |
| ({ status: 200, description: 'Progress saved', type: QuizAttempt }) | |
| ({ status: 400, description: 'Attempt is not in progress' }) | |
| ({ status: 404, description: 'Attempt not found' }) | |
| async saveAttemptProgress( | |
| ('quizId', ParseIntPipe) quizId: number, | |
| ('attemptId', ParseIntPipe) attemptId: number, | |
| () dto: SubmitQuizDto, | |
| () req: any, | |
| ): Promise<QuizAttempt> { | |
| return this.quizzesService.saveAttemptProgress(quizId, attemptId, req.user.userId, dto); | |
| } | |
| ('attempts/:attemptId/submit') | |
| (RoleName.STUDENT) | |
| ({ | |
| 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. | |
| `, | |
| }) | |
| ({ name: 'attemptId', description: 'Attempt ID', type: Number }) | |
| ({ type: SubmitQuizDto }) | |
| ({ status: 200, description: 'Quiz submitted, returns results', type: AttemptResultDto }) | |
| ({ status: 400, description: 'Time expired or already submitted' }) | |
| ({ status: 404, description: 'Attempt not found' }) | |
| async submitAttempt( | |
| ('attemptId', ParseIntPipe) attemptId: number, | |
| () dto: SubmitQuizDto, | |
| () req: any, | |
| ): Promise<AttemptResultDto> { | |
| return this.quizzesService.submitAttempt(attemptId, req.user.userId, dto); | |
| } | |
| ('attempts/:attemptId') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN, RoleName.STUDENT) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'attemptId', description: 'Attempt ID', type: Number }) | |
| ({ status: 200, description: 'Attempt result', type: AttemptResultDto }) | |
| ({ status: 404, description: 'Attempt not found' }) | |
| async getAttemptResult( | |
| ('attemptId', ParseIntPipe) attemptId: number, | |
| () req: any, | |
| ): Promise<AttemptResultDto> { | |
| return this.quizzesService.getAttemptResult(attemptId, req.user.userId); | |
| } | |
| // ============ GRADING ============ | |
| ('attempts/:attemptId/grade') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'attemptId', description: 'Attempt ID', type: Number }) | |
| ({ type: ManualGradeDto }) | |
| ({ status: 200, description: 'Grades applied', type: AttemptResultDto }) | |
| ({ status: 404, description: 'Attempt not found' }) | |
| async applyManualGrades( | |
| ('attemptId', ParseIntPipe) attemptId: number, | |
| () dto: ManualGradeDto, | |
| () req: any, | |
| ): Promise<AttemptResultDto> { | |
| const graderId = req.user.userId; | |
| return this.gradingService.applyManualGrades(attemptId, dto, graderId); | |
| } | |
| ('attempts/:attemptId/pending-grading') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'attemptId', description: 'Attempt ID', type: Number }) | |
| ({ status: 200, description: 'Questions needing grading' }) | |
| async getQuestionsNeedingGrading(('attemptId', ParseIntPipe) attemptId: number) { | |
| return this.gradingService.getQuestionsNeedingGrading(attemptId); | |
| } | |
| // ============ STATISTICS & PROGRESS ============ | |
| (':quizId/statistics') | |
| (RoleName.INSTRUCTOR, RoleName.TA, RoleName.ADMIN) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'quizId', description: 'Quiz ID', type: Number }) | |
| ({ status: 200, description: 'Quiz statistics', type: QuizStatisticsDto }) | |
| ({ status: 404, description: 'Quiz not found' }) | |
| async getQuizStatistics(('quizId', ParseIntPipe) quizId: number): Promise<QuizStatisticsDto> { | |
| return this.quizzesService.getQuizStatistics(quizId); | |
| } | |
| ('progress/course/:courseId') | |
| (RoleName.STUDENT) | |
| ({ | |
| 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 | |
| `, | |
| }) | |
| ({ name: 'courseId', description: 'Course ID', type: Number }) | |
| ({ status: 200, description: 'Student progress', type: StudentQuizProgressDto }) | |
| async getStudentProgress( | |
| ('courseId', ParseIntPipe) courseId: number, | |
| () req: any, | |
| ): Promise<StudentQuizProgressDto> { | |
| return this.quizzesService.getStudentProgress(req.user.userId, courseId); | |
| } | |
| } | |