Implement vocab course and grammar exercise features in backend and frontend
- Added new course management functionalities in VocabController, including creating, updating, and deleting courses and lessons. - Implemented enrollment and progress tracking for courses, along with grammar exercise creation and management. - Updated database schema to include tables for courses, lessons, enrollments, and grammar exercises. - Enhanced frontend with new routes and views for course listing and details, including internationalization support for course-related texts. - Improved user experience by adding navigation to courses from the main vocab trainer view.
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
import crypto from 'crypto';
|
||||
import User from '../models/community/user.js';
|
||||
import VocabCourse from '../models/community/vocab_course.js';
|
||||
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||
import VocabCourseEnrollment from '../models/community/vocab_course_enrollment.js';
|
||||
import VocabCourseProgress from '../models/community/vocab_course_progress.js';
|
||||
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
|
||||
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import { notifyUser } from '../utils/socket.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
export default class VocabService {
|
||||
async _getUserByHashedId(hashedUserId) {
|
||||
@@ -527,6 +535,679 @@ export default class VocabService {
|
||||
return { created: Boolean(mapping?.id) };
|
||||
});
|
||||
}
|
||||
|
||||
// ========== COURSE METHODS ==========
|
||||
|
||||
async createCourse(hashedUserId, { title, description, languageId, difficultyLevel = 1, isPublic = false }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
|
||||
// Prüfe Zugriff auf Sprache
|
||||
await this._getLanguageAccess(user.id, languageId);
|
||||
|
||||
const shareCode = isPublic ? crypto.randomBytes(8).toString('hex') : null;
|
||||
|
||||
const course = await VocabCourse.create({
|
||||
ownerUserId: user.id,
|
||||
title,
|
||||
description,
|
||||
languageId: Number(languageId),
|
||||
difficultyLevel: Number(difficultyLevel) || 1,
|
||||
isPublic: Boolean(isPublic),
|
||||
shareCode
|
||||
});
|
||||
|
||||
return course.get({ plain: true });
|
||||
}
|
||||
|
||||
async getCourses(hashedUserId, { includePublic = true, includeOwn = true } = {}) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
|
||||
const where = {};
|
||||
if (includeOwn && includePublic) {
|
||||
where[Op.or] = [
|
||||
{ ownerUserId: user.id },
|
||||
{ isPublic: true }
|
||||
];
|
||||
} else if (includeOwn) {
|
||||
where.ownerUserId = user.id;
|
||||
} else if (includePublic) {
|
||||
where.isPublic = true;
|
||||
}
|
||||
|
||||
const courses = await VocabCourse.findAll({
|
||||
where,
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
return courses.map(c => c.get({ plain: true }));
|
||||
}
|
||||
|
||||
async getCourse(hashedUserId, courseId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const course = await VocabCourse.findByPk(courseId, {
|
||||
include: [
|
||||
{
|
||||
model: VocabCourseLesson,
|
||||
as: 'lessons',
|
||||
order: [['lessonNumber', 'ASC']]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
const err = new Error('Course not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Prüfe Zugriff
|
||||
if (course.ownerUserId !== user.id && !course.isPublic) {
|
||||
const err = new Error('Access denied');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const courseData = course.get({ plain: true });
|
||||
courseData.lessons = courseData.lessons || [];
|
||||
|
||||
// Sortiere Lektionen nach Woche, Tag, dann Nummer
|
||||
courseData.lessons.sort((a, b) => {
|
||||
if (a.weekNumber !== b.weekNumber) {
|
||||
return (a.weekNumber || 999) - (b.weekNumber || 999);
|
||||
}
|
||||
if (a.dayNumber !== b.dayNumber) {
|
||||
return (a.dayNumber || 999) - (b.dayNumber || 999);
|
||||
}
|
||||
return a.lessonNumber - b.lessonNumber;
|
||||
});
|
||||
|
||||
return courseData;
|
||||
}
|
||||
|
||||
async updateCourse(hashedUserId, courseId, { title, description, difficultyLevel, isPublic }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const course = await VocabCourse.findByPk(courseId);
|
||||
|
||||
if (!course) {
|
||||
const err = new Error('Course not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (course.ownerUserId !== user.id) {
|
||||
const err = new Error('Only the owner can update the course');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (difficultyLevel !== undefined) updates.difficultyLevel = Number(difficultyLevel);
|
||||
if (isPublic !== undefined) {
|
||||
updates.isPublic = Boolean(isPublic);
|
||||
// Generiere Share-Code wenn Kurs öffentlich wird
|
||||
if (isPublic && !course.shareCode) {
|
||||
updates.shareCode = crypto.randomBytes(8).toString('hex');
|
||||
} else if (!isPublic) {
|
||||
updates.shareCode = null;
|
||||
}
|
||||
}
|
||||
|
||||
await course.update(updates);
|
||||
return course.get({ plain: true });
|
||||
}
|
||||
|
||||
async deleteCourse(hashedUserId, courseId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const course = await VocabCourse.findByPk(courseId);
|
||||
|
||||
if (!course) {
|
||||
const err = new Error('Course not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (course.ownerUserId !== user.id) {
|
||||
const err = new Error('Only the owner can delete the course');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
await course.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const course = await VocabCourse.findByPk(courseId);
|
||||
|
||||
if (!course) {
|
||||
const err = new Error('Course not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (course.ownerUserId !== user.id) {
|
||||
const err = new Error('Only the owner can add lessons');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Prüfe, ob Kapitel zur gleichen Sprache gehört (nur wenn chapterId angegeben)
|
||||
if (chapterId) {
|
||||
const [chapter] = await sequelize.query(
|
||||
`SELECT language_id FROM community.vocab_chapter WHERE id = :chapterId`,
|
||||
{
|
||||
replacements: { chapterId: Number(chapterId) },
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
|
||||
if (!chapter || chapter.language_id !== course.languageId) {
|
||||
const err = new Error('Chapter does not belong to the course language');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const lesson = await VocabCourseLesson.create({
|
||||
courseId: course.id,
|
||||
chapterId: chapterId ? Number(chapterId) : null,
|
||||
lessonNumber: Number(lessonNumber),
|
||||
title,
|
||||
description,
|
||||
weekNumber: weekNumber ? Number(weekNumber) : null,
|
||||
dayNumber: dayNumber ? Number(dayNumber) : null,
|
||||
lessonType: lessonType || 'vocab',
|
||||
audioUrl: audioUrl || null,
|
||||
culturalNotes: culturalNotes || null,
|
||||
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
|
||||
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
|
||||
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
|
||||
});
|
||||
|
||||
return lesson.get({ plain: true });
|
||||
}
|
||||
|
||||
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
||||
include: [{ model: VocabCourse, as: 'course' }]
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
const err = new Error('Lesson not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (lesson.course.ownerUserId !== user.id) {
|
||||
const err = new Error('Only the owner can update lessons');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (lessonNumber !== undefined) updates.lessonNumber = Number(lessonNumber);
|
||||
if (weekNumber !== undefined) updates.weekNumber = weekNumber ? Number(weekNumber) : null;
|
||||
if (dayNumber !== undefined) updates.dayNumber = dayNumber ? Number(dayNumber) : null;
|
||||
if (lessonType !== undefined) updates.lessonType = lessonType;
|
||||
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
|
||||
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
|
||||
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
|
||||
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
|
||||
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
|
||||
|
||||
await lesson.update(updates);
|
||||
return lesson.get({ plain: true });
|
||||
}
|
||||
|
||||
async deleteLesson(hashedUserId, lessonId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
||||
include: [{ model: VocabCourse, as: 'course' }]
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
const err = new Error('Lesson not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (lesson.course.ownerUserId !== user.id) {
|
||||
const err = new Error('Only the owner can delete lessons');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
await lesson.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async enrollInCourse(hashedUserId, courseId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const course = await VocabCourse.findByPk(courseId);
|
||||
|
||||
if (!course) {
|
||||
const err = new Error('Course not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Prüfe Zugriff
|
||||
if (course.ownerUserId !== user.id && !course.isPublic) {
|
||||
const err = new Error('Course is not public');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [enrollment, created] = await VocabCourseEnrollment.findOrCreate({
|
||||
where: { userId: user.id, courseId: course.id },
|
||||
defaults: { userId: user.id, courseId: course.id }
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
const err = new Error('Already enrolled in this course');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return enrollment.get({ plain: true });
|
||||
}
|
||||
|
||||
async unenrollFromCourse(hashedUserId, courseId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const enrollment = await VocabCourseEnrollment.findOne({
|
||||
where: { userId: user.id, courseId: Number(courseId) }
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
const err = new Error('Not enrolled in this course');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
await enrollment.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getMyCourses(hashedUserId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
|
||||
const enrollments = await VocabCourseEnrollment.findAll({
|
||||
where: { userId: user.id },
|
||||
include: [{ model: VocabCourse, as: 'course' }],
|
||||
order: [['enrolledAt', 'DESC']]
|
||||
});
|
||||
|
||||
return enrollments.map(e => ({
|
||||
...e.course.get({ plain: true }),
|
||||
enrolledAt: e.enrolledAt
|
||||
}));
|
||||
}
|
||||
|
||||
async getCourseProgress(hashedUserId, courseId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
|
||||
// Prüfe Einschreibung
|
||||
const enrollment = await VocabCourseEnrollment.findOne({
|
||||
where: { userId: user.id, courseId: Number(courseId) }
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
const err = new Error('Not enrolled in this course');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const progress = await VocabCourseProgress.findAll({
|
||||
where: { userId: user.id, courseId: Number(courseId) },
|
||||
include: [{ model: VocabCourseLesson, as: 'lesson' }],
|
||||
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']]
|
||||
});
|
||||
|
||||
return progress.map(p => p.get({ plain: true }));
|
||||
}
|
||||
|
||||
async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
||||
include: [{ model: VocabCourse, as: 'course' }]
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
const err = new Error('Lesson not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Prüfe Einschreibung
|
||||
const enrollment = await VocabCourseEnrollment.findOne({
|
||||
where: { userId: user.id, courseId: lesson.courseId }
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
const err = new Error('Not enrolled in this course');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const lessonData = await VocabCourseLesson.findByPk(lesson.id);
|
||||
const targetScore = lessonData.targetScorePercent || 80;
|
||||
const actualScore = Number(score) || 0;
|
||||
const hasReachedTarget = actualScore >= targetScore;
|
||||
|
||||
// Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true)
|
||||
const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false);
|
||||
|
||||
const [progress, created] = await VocabCourseProgress.findOrCreate({
|
||||
where: { userId: user.id, lessonId: lesson.id },
|
||||
defaults: {
|
||||
userId: user.id,
|
||||
courseId: lesson.courseId,
|
||||
lessonId: lesson.id,
|
||||
completed: isCompleted,
|
||||
score: actualScore,
|
||||
lastAccessedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
const updates = { lastAccessedAt: new Date() };
|
||||
if (score !== undefined) {
|
||||
updates.score = Math.max(progress.score, actualScore);
|
||||
// Prüfe, ob Ziel jetzt erreicht wurde
|
||||
if (updates.score >= targetScore && !progress.completed) {
|
||||
if (!lessonData.requiresReview) {
|
||||
updates.completed = true;
|
||||
updates.completedAt = new Date();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (completed !== undefined) {
|
||||
updates.completed = Boolean(completed);
|
||||
if (completed && !progress.completedAt) {
|
||||
updates.completedAt = new Date();
|
||||
}
|
||||
}
|
||||
await progress.update(updates);
|
||||
} else if (isCompleted) {
|
||||
progress.completed = true;
|
||||
progress.completedAt = new Date();
|
||||
await progress.save();
|
||||
}
|
||||
|
||||
const progressData = progress.get({ plain: true });
|
||||
progressData.targetScore = targetScore;
|
||||
progressData.hasReachedTarget = progressData.score >= targetScore;
|
||||
progressData.needsReview = lessonData.requiresReview && !progressData.hasReachedTarget;
|
||||
|
||||
return progressData;
|
||||
}
|
||||
|
||||
// ========== GRAMMAR EXERCISE METHODS ==========
|
||||
|
||||
async getExerciseTypes() {
|
||||
const types = await VocabGrammarExerciseType.findAll({
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
return types.map(t => t.get({ plain: true }));
|
||||
}
|
||||
|
||||
async createGrammarExercise(hashedUserId, lessonId, { exerciseTypeId, exerciseNumber, title, instruction, questionData, answerData, explanation }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
||||
include: [{ model: VocabCourse, as: 'course' }]
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
const err = new Error('Lesson not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Prüfe, ob User Besitzer des Kurses ist
|
||||
if (lesson.course.ownerUserId !== user.id) {
|
||||
const err = new Error('Only the owner can add grammar exercises');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const exercise = await VocabGrammarExercise.create({
|
||||
lessonId: lesson.id,
|
||||
exerciseTypeId: Number(exerciseTypeId),
|
||||
exerciseNumber: Number(exerciseNumber),
|
||||
title,
|
||||
instruction,
|
||||
questionData,
|
||||
answerData,
|
||||
explanation,
|
||||
createdByUserId: user.id
|
||||
});
|
||||
|
||||
return exercise.get({ plain: true });
|
||||
}
|
||||
|
||||
async getGrammarExercisesForLesson(hashedUserId, lessonId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
||||
include: [{ model: VocabCourse, as: 'course' }]
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
const err = new Error('Lesson not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Prüfe Zugriff
|
||||
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
|
||||
const err = new Error('Access denied');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const exercises = await VocabGrammarExercise.findAll({
|
||||
where: { lessonId: lesson.id },
|
||||
include: [{ model: VocabGrammarExerciseType, as: 'exerciseType' }],
|
||||
order: [['exerciseNumber', 'ASC']]
|
||||
});
|
||||
|
||||
return exercises.map(e => e.get({ plain: true }));
|
||||
}
|
||||
|
||||
async getGrammarExercise(hashedUserId, exerciseId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
||||
include: [
|
||||
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] },
|
||||
{ model: VocabGrammarExerciseType, as: 'exerciseType' }
|
||||
]
|
||||
});
|
||||
|
||||
if (!exercise) {
|
||||
const err = new Error('Exercise not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Prüfe Zugriff
|
||||
if (exercise.lesson.course.ownerUserId !== user.id && !exercise.lesson.course.isPublic) {
|
||||
const err = new Error('Access denied');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return exercise.get({ plain: true });
|
||||
}
|
||||
|
||||
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
||||
include: [
|
||||
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
||||
]
|
||||
});
|
||||
|
||||
if (!exercise) {
|
||||
const err = new Error('Exercise not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Prüfe Einschreibung
|
||||
const enrollment = await VocabCourseEnrollment.findOne({
|
||||
where: { userId: user.id, courseId: exercise.lesson.courseId }
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
const err = new Error('Not enrolled in this course');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Überprüfe Antwort (vereinfachte Logik - kann je nach Übungstyp erweitert werden)
|
||||
const isCorrect = this._checkAnswer(exercise.answerData, userAnswer, exercise.exerciseTypeId);
|
||||
|
||||
// Speichere Fortschritt
|
||||
const [progress, created] = await VocabGrammarExerciseProgress.findOrCreate({
|
||||
where: { userId: user.id, exerciseId: exercise.id },
|
||||
defaults: {
|
||||
userId: user.id,
|
||||
exerciseId: exercise.id,
|
||||
attempts: 1,
|
||||
correctAttempts: isCorrect ? 1 : 0,
|
||||
lastAttemptAt: new Date(),
|
||||
completed: false
|
||||
}
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
progress.attempts += 1;
|
||||
if (isCorrect) {
|
||||
progress.correctAttempts += 1;
|
||||
if (!progress.completed) {
|
||||
progress.completed = true;
|
||||
progress.completedAt = new Date();
|
||||
}
|
||||
}
|
||||
progress.lastAttemptAt = new Date();
|
||||
await progress.save();
|
||||
} else if (isCorrect) {
|
||||
progress.completed = true;
|
||||
progress.completedAt = new Date();
|
||||
await progress.save();
|
||||
}
|
||||
|
||||
return {
|
||||
correct: isCorrect,
|
||||
explanation: exercise.explanation,
|
||||
progress: progress.get({ plain: true })
|
||||
};
|
||||
}
|
||||
|
||||
_checkAnswer(answerData, userAnswer, exerciseTypeId) {
|
||||
// Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden
|
||||
if (!answerData || !userAnswer) return false;
|
||||
|
||||
// Für Multiple Choice: Prüfe ob userAnswer eine der richtigen Antworten ist
|
||||
if (exerciseTypeId === 2) { // multiple_choice
|
||||
const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct];
|
||||
return correctAnswers.includes(userAnswer);
|
||||
}
|
||||
|
||||
// Für Lückentext: Normalisiere und vergleiche
|
||||
if (exerciseTypeId === 1) { // gap_fill
|
||||
const normalize = (str) => String(str || '').trim().toLowerCase();
|
||||
const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct];
|
||||
const normalizedUserAnswer = normalize(userAnswer);
|
||||
return correctAnswers.some(correct => normalize(correct) === normalizedUserAnswer);
|
||||
}
|
||||
|
||||
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
|
||||
const normalize = (str) => String(str || '').trim().toLowerCase();
|
||||
const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct];
|
||||
return correctAnswers.some(correct => normalize(correct) === normalize(userAnswer));
|
||||
}
|
||||
|
||||
async getGrammarExerciseProgress(hashedUserId, lessonId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId);
|
||||
|
||||
const exerciseIds = exercises.map(e => e.id);
|
||||
const progress = await VocabGrammarExerciseProgress.findAll({
|
||||
where: {
|
||||
userId: user.id,
|
||||
exerciseId: { [Op.in]: exerciseIds }
|
||||
}
|
||||
});
|
||||
|
||||
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
|
||||
|
||||
return exercises.map(exercise => ({
|
||||
...exercise,
|
||||
progress: progressMap.get(exercise.id) || null
|
||||
}));
|
||||
}
|
||||
|
||||
async updateGrammarExercise(hashedUserId, exerciseId, { title, instruction, questionData, answerData, explanation, exerciseNumber }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
||||
include: [
|
||||
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
||||
]
|
||||
});
|
||||
|
||||
if (!exercise) {
|
||||
const err = new Error('Exercise not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (exercise.lesson.course.ownerUserId !== user.id) {
|
||||
const err = new Error('Only the owner can update exercises');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (instruction !== undefined) updates.instruction = instruction;
|
||||
if (questionData !== undefined) updates.questionData = questionData;
|
||||
if (answerData !== undefined) updates.answerData = answerData;
|
||||
if (explanation !== undefined) updates.explanation = explanation;
|
||||
if (exerciseNumber !== undefined) updates.exerciseNumber = Number(exerciseNumber);
|
||||
|
||||
await exercise.update(updates);
|
||||
return exercise.get({ plain: true });
|
||||
}
|
||||
|
||||
async deleteGrammarExercise(hashedUserId, exerciseId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
||||
include: [
|
||||
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
||||
]
|
||||
});
|
||||
|
||||
if (!exercise) {
|
||||
const err = new Error('Exercise not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (exercise.lesson.course.ownerUserId !== user.id) {
|
||||
const err = new Error('Only the owner can delete exercises');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
await exercise.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user