286 lines
8.3 KiB
JavaScript
286 lines
8.3 KiB
JavaScript
/**
|
|
* 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<number>} 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;
|