/** * Base class for all lesson modules * All lesson modules should extend this class */ class LessonModule { constructor(config) { this.config = config; this.lessonKey = config.lessonKey; } /** * Validate an answer for a specific question * @param {string} questionId - The question identifier * @param {any} answer - The participant's answer * @returns {Object} { isCorrect, pointsAwarded, feedback } */ async validateAnswer(questionId, answer) { const step = this.config.steps.find(s => s.id === questionId); if (!step || step.type !== 'question') { throw new Error(`Question ${questionId} not found`); } return this._validateQuestionType(step, answer); } /** * Internal validation based on question type */ _validateQuestionType(step, answer) { switch (step.questionType) { case 'single_choice': return this._validateSingleChoice(step, answer); case 'multiple_choice': return this._validateMultipleChoice(step, answer); case 'free_text': return this._validateFreeText(step, answer); default: throw new Error(`Unknown question type: ${step.questionType}`); } } _validateSingleChoice(step, answer) { const selectedOption = step.options.find(opt => opt.id === answer); if (!selectedOption) { return { isCorrect: false, pointsAwarded: 0, feedback: step.feedback?.incorrect || 'Incorrect answer' }; } return { isCorrect: selectedOption.isCorrect, pointsAwarded: selectedOption.isCorrect ? selectedOption.points : 0, feedback: selectedOption.isCorrect ? (step.feedback?.correct || 'Correct!') : (step.feedback?.incorrect || 'Incorrect answer') }; } _validateMultipleChoice(step, answers) { // answers should be an array of option IDs if (!Array.isArray(answers)) { return { isCorrect: false, pointsAwarded: 0, feedback: step.feedback?.incorrect || 'Invalid answer format' }; } const correctOptions = step.options.filter(opt => opt.isCorrect).map(opt => opt.id); const selectedCorrect = answers.filter(a => correctOptions.includes(a)); const selectedIncorrect = answers.filter(a => !correctOptions.includes(a)); // Calculate points const pointsAwarded = selectedCorrect.reduce((sum, id) => { const option = step.options.find(opt => opt.id === id); return sum + (option?.points || 0); }, 0); const isFullyCorrect = selectedCorrect.length === correctOptions.length && selectedIncorrect.length === 0; const isPartiallyCorrect = selectedCorrect.length > 0 && !isFullyCorrect; let feedback = step.feedback?.incorrect || 'Incorrect answer'; if (isFullyCorrect) { feedback = step.feedback?.correct || 'Correct!'; } else if (isPartiallyCorrect) { feedback = step.feedback?.partial || step.feedback?.correct || 'Partially correct'; } return { isCorrect: isFullyCorrect, isPartial: isPartiallyCorrect, pointsAwarded, feedback }; } _validateFreeText(step, answer) { if (!answer || typeof answer !== 'string') { return { isCorrect: false, pointsAwarded: 0, feedback: step.feedback?.incorrect || 'Answer is required' }; } if (!step.validationRules || step.validationRules.length === 0) { // No validation rules, accept any non-empty answer const points = answer.trim().length > 0 ? step.maxPoints : 0; return { isCorrect: points > 0, pointsAwarded: points, feedback: points > 0 ? (step.feedback?.correct || 'Answer received') : (step.feedback?.incorrect || 'Answer is too short') }; } let passedRules = 0; const totalRules = step.validationRules.length; for (const rule of step.validationRules) { if (this._checkValidationRule(rule, answer)) { passedRules++; } } const scorePercentage = passedRules / totalRules; const pointsAwarded = Math.round(step.maxPoints * scorePercentage); const isCorrect = scorePercentage >= 0.7; // 70% threshold return { isCorrect, pointsAwarded, feedback: isCorrect ? (step.feedback?.correct || 'Good answer!') : (step.feedback?.incorrect || 'Please review your answer') }; } _checkValidationRule(rule, answer) { const lowerAnswer = (answer || '').toLowerCase(); switch (rule.type) { case 'contains_keywords': const matches = rule.keywords.filter(keyword => lowerAnswer.includes(keyword.toLowerCase()) ).length; return matches >= (rule.minMatches || 1); case 'min_length': return answer.length >= rule.value; case 'max_length': return answer.length <= rule.value; case 'regex': return new RegExp(rule.pattern, rule.flags || 'i').test(answer); default: return false; } } /** * Get interactive component data for a step * Can be overridden by subclasses for dynamic content */ async getInteractiveData(stepId) { const step = this.config.steps.find(s => s.id === stepId); if (!step || step.type !== 'interactive') { throw new Error(`Interactive step ${stepId} not found`); } return { component: step.interactiveComponent, props: step.componentProps || {} }; } /** * Get lesson content for rendering (without answers) */ async getContent() { // Map steps and fetch interactive data for interactive steps const steps = await Promise.all(this.config.steps.map(async step => { const baseStep = { id: step.id, type: step.type, title: step.title, content: step.content }; // For question steps, don't send correct answers if (step.type === 'question') { return { ...baseStep, questionType: step.questionType, question: step.question, maxPoints: step.maxPoints, options: step.options?.map(opt => ({ id: opt.id, text: opt.text // isCorrect and points are intentionally omitted })) }; } // For interactive steps, fetch and include interactive data if (step.type === 'interactive') { const interactiveData = await this.getInteractiveData(step.id); return { ...baseStep, interactiveComponent: step.interactiveComponent, componentProps: step.componentProps, interactiveData }; } return baseStep; })); return { lessonKey: this.lessonKey, title: this.config.title, description: this.config.description, difficultyLevel: this.config.difficultyLevel, estimatedDuration: this.config.estimatedDuration, steps, scoring: { maxTotalPoints: this.config.scoring?.maxTotalPoints || 100, passingScore: this.config.scoring?.passingScore || 70 } }; } /** * Award points for interactive component discoveries * This method can be called by lesson modules to award points dynamically * @param {number} participantId - Participant ID * @param {number} eventLessonId - Event lesson ID * @param {number} points - Points to award * @param {string} reason - Reason for points (for tracking) * @returns {Promise} New total score */ async awardPoints(participantId, eventLessonId, points, reason) { const progressQueries = require('../../src/models/queries/progress.queries'); // Get or create progress let progress = await progressQueries.getLessonProgress(participantId, eventLessonId); if (!progress) { // Auto-start lesson if not started progress = await progressQueries.startLesson(participantId, eventLessonId); } // Award points const newScore = await progressQueries.updateScore(progress.id, points); // Optionally save the discovery reason for tracking if (reason) { await progressQueries.saveAnswer( progress.id, `interactive-${Date.now()}`, { type: 'interactive', reason }, true, points, reason ); } return newScore; } /** * Get full configuration (for debugging/admin) */ getFullConfig() { return this.config; } } module.exports = LessonModule;