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) { const user = await User.findOne({ where: { hashedId: hashedUserId } }); if (!user) { const err = new Error('User not found'); err.status = 404; throw err; } return user; } _normalizeLexeme(text) { return String(text || '') .trim() .toLowerCase() .replace(/\s+/g, ' '); } async _getLanguageAccess(userId, languageId) { const id = Number.parseInt(languageId, 10); if (!Number.isFinite(id)) { const err = new Error('Invalid language id'); err.status = 400; throw err; } const [row] = await sequelize.query( ` SELECT l.id, (l.owner_user_id = :userId) AS "isOwner" FROM community.vocab_language l WHERE l.id = :languageId AND ( l.owner_user_id = :userId OR EXISTS ( SELECT 1 FROM community.vocab_language_subscription s WHERE s.user_id = :userId AND s.language_id = l.id ) ) LIMIT 1 `, { replacements: { userId, languageId: id }, type: sequelize.QueryTypes.SELECT, } ); if (!row) { const err = new Error('Language not found or no access'); err.status = 404; throw err; } return row; } async _getChapterAccess(userId, chapterId) { const id = Number.parseInt(chapterId, 10); if (!Number.isFinite(id)) { const err = new Error('Invalid chapter id'); err.status = 400; throw err; } const [row] = await sequelize.query( ` SELECT c.id, c.language_id AS "languageId", c.title, (l.owner_user_id = :userId) AS "isOwner" FROM community.vocab_chapter c JOIN community.vocab_language l ON l.id = c.language_id WHERE c.id = :chapterId AND ( l.owner_user_id = :userId OR EXISTS ( SELECT 1 FROM community.vocab_language_subscription s WHERE s.user_id = :userId AND s.language_id = l.id ) ) LIMIT 1 `, { replacements: { userId, chapterId: id }, type: sequelize.QueryTypes.SELECT, } ); if (!row) { const err = new Error('Chapter not found or no access'); err.status = 404; throw err; } return row; } async listLanguages(hashedUserId) { const user = await this._getUserByHashedId(hashedUserId); const rows = await sequelize.query( ` SELECT l.id, l.name, l.share_code AS "shareCode", TRUE AS "isOwner" FROM community.vocab_language l WHERE l.owner_user_id = :userId UNION ALL SELECT l.id, l.name, NULL::text AS "shareCode", FALSE AS "isOwner" FROM community.vocab_language_subscription s JOIN community.vocab_language l ON l.id = s.language_id WHERE s.user_id = :userId ORDER BY name ASC `, { replacements: { userId: user.id }, type: sequelize.QueryTypes.SELECT, } ); return { languages: rows }; } async listLanguagesForMenu(userId) { // userId ist die numerische community.user.id const rows = await sequelize.query( ` SELECT l.id, l.name FROM community.vocab_language l WHERE l.owner_user_id = :userId UNION SELECT l.id, l.name FROM community.vocab_language_subscription s JOIN community.vocab_language l ON l.id = s.language_id WHERE s.user_id = :userId ORDER BY name ASC `, { replacements: { userId }, type: sequelize.QueryTypes.SELECT, } ); return rows; } async createLanguage(hashedUserId, { name }) { const user = await this._getUserByHashedId(hashedUserId); const cleanName = typeof name === 'string' ? name.trim() : ''; if (!cleanName || cleanName.length < 2 || cleanName.length > 60) { const err = new Error('Invalid language name'); err.status = 400; throw err; } // 16 hex chars => ausreichend kurz, gut teilbar const shareCode = crypto.randomBytes(8).toString('hex'); const [created] = await sequelize.query( ` INSERT INTO community.vocab_language (owner_user_id, name, share_code) VALUES (:ownerUserId, :name, :shareCode) RETURNING id, name, share_code AS "shareCode" `, { replacements: { ownerUserId: user.id, name: cleanName, shareCode }, type: sequelize.QueryTypes.SELECT, } ); // Menü dynamisch nachladen (bei allen offenen Tabs/Clients) try { notifyUser(user.hashedId, 'reloadmenu', {}); } catch (_) {} return created; } async subscribeByShareCode(hashedUserId, { shareCode }) { const user = await this._getUserByHashedId(hashedUserId); const code = typeof shareCode === 'string' ? shareCode.trim() : ''; if (!code || code.length < 6 || code.length > 128) { const err = new Error('Invalid share code'); err.status = 400; throw err; } const [lang] = await sequelize.query( ` SELECT id, owner_user_id AS "ownerUserId", name FROM community.vocab_language WHERE share_code = :shareCode LIMIT 1 `, { replacements: { shareCode: code }, type: sequelize.QueryTypes.SELECT, } ); if (!lang) { const err = new Error('Language not found'); err.status = 404; throw err; } // Owner braucht kein Abo if (lang.ownerUserId === user.id) { return { subscribed: false, message: 'Already owner', languageId: lang.id }; } await sequelize.query( ` INSERT INTO community.vocab_language_subscription (user_id, language_id) VALUES (:userId, :languageId) ON CONFLICT (user_id, language_id) DO NOTHING `, { replacements: { userId: user.id, languageId: lang.id }, type: sequelize.QueryTypes.INSERT, } ); try { notifyUser(user.hashedId, 'reloadmenu', {}); } catch (_) {} return { subscribed: true, languageId: lang.id, name: lang.name }; } async getLanguage(hashedUserId, languageId) { const user = await this._getUserByHashedId(hashedUserId); const id = Number.parseInt(languageId, 10); if (!Number.isFinite(id)) { const err = new Error('Invalid language id'); err.status = 400; throw err; } const [row] = await sequelize.query( ` SELECT l.id, l.name, CASE WHEN l.owner_user_id = :userId THEN l.share_code ELSE NULL END AS "shareCode", (l.owner_user_id = :userId) AS "isOwner" FROM community.vocab_language l WHERE l.id = :languageId AND ( l.owner_user_id = :userId OR EXISTS ( SELECT 1 FROM community.vocab_language_subscription s WHERE s.user_id = :userId AND s.language_id = l.id ) ) LIMIT 1 `, { replacements: { userId: user.id, languageId: id }, type: sequelize.QueryTypes.SELECT, } ); if (!row) { const err = new Error('Language not found or no access'); err.status = 404; throw err; } return row; } async listChapters(hashedUserId, languageId) { const user = await this._getUserByHashedId(hashedUserId); const access = await this._getLanguageAccess(user.id, languageId); const rows = await sequelize.query( ` SELECT c.id, c.title, c.created_at AS "createdAt", ( SELECT COUNT(*) FROM community.vocab_chapter_lexeme cl WHERE cl.chapter_id = c.id )::int AS "vocabCount" FROM community.vocab_chapter c WHERE c.language_id = :languageId ORDER BY c.title ASC `, { replacements: { languageId: access.id }, type: sequelize.QueryTypes.SELECT, } ); return { chapters: rows, isOwner: access.isOwner }; } async createChapter(hashedUserId, languageId, { title }) { const user = await this._getUserByHashedId(hashedUserId); const access = await this._getLanguageAccess(user.id, languageId); if (!access.isOwner) { const err = new Error('Only owner can create chapters'); err.status = 403; throw err; } const cleanTitle = typeof title === 'string' ? title.trim() : ''; if (!cleanTitle || cleanTitle.length < 2 || cleanTitle.length > 80) { const err = new Error('Invalid chapter title'); err.status = 400; throw err; } const [created] = await sequelize.query( ` INSERT INTO community.vocab_chapter (language_id, title, created_by_user_id) VALUES (:languageId, :title, :userId) RETURNING id, title, created_at AS "createdAt" `, { replacements: { languageId: access.id, title: cleanTitle, userId: user.id }, type: sequelize.QueryTypes.SELECT, } ); return created; } async getChapter(hashedUserId, chapterId) { const user = await this._getUserByHashedId(hashedUserId); const ch = await this._getChapterAccess(user.id, chapterId); return { id: ch.id, languageId: ch.languageId, title: ch.title, isOwner: ch.isOwner }; } async listChapterVocabs(hashedUserId, chapterId) { const user = await this._getUserByHashedId(hashedUserId); const ch = await this._getChapterAccess(user.id, chapterId); const rows = await sequelize.query( ` SELECT cl.id, l1.text AS "learning", l2.text AS "reference", cl.created_at AS "createdAt" FROM community.vocab_chapter_lexeme cl JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id WHERE cl.chapter_id = :chapterId ORDER BY l1.text ASC, l2.text ASC `, { replacements: { chapterId: ch.id }, type: sequelize.QueryTypes.SELECT, } ); return { chapter: { id: ch.id, title: ch.title, languageId: ch.languageId, isOwner: ch.isOwner }, vocabs: rows }; } async listLanguageVocabs(hashedUserId, languageId) { const user = await this._getUserByHashedId(hashedUserId); const access = await this._getLanguageAccess(user.id, languageId); const rows = await sequelize.query( ` SELECT cl.id, c.id AS "chapterId", c.title AS "chapterTitle", l1.text AS "learning", l2.text AS "reference", cl.created_at AS "createdAt" FROM community.vocab_chapter_lexeme cl JOIN community.vocab_chapter c ON c.id = cl.chapter_id JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id WHERE c.language_id = :languageId ORDER BY c.title ASC, l1.text ASC, l2.text ASC `, { replacements: { languageId: access.id }, type: sequelize.QueryTypes.SELECT, } ); return { languageId: access.id, isOwner: access.isOwner, vocabs: rows }; } async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) { const user = await this._getUserByHashedId(hashedUserId); const access = await this._getLanguageAccess(user.id, languageId); const query = typeof q === 'string' ? q.trim() : ''; // Abwärtskompatibel: falls alte Parameter genutzt werden, zusammenfassen const learningTerm = typeof learning === 'string' ? learning.trim() : ''; const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : ''; const effective = query || learningTerm || motherTerm; if (!effective) { const err = new Error('Missing search term'); err.status = 400; throw err; } const like = `%${effective}%`; const rows = await sequelize.query( ` SELECT cl.id, c.id AS "chapterId", c.title AS "chapterTitle", l1.text AS "learning", l2.text AS "motherTongue" FROM community.vocab_chapter_lexeme cl JOIN community.vocab_chapter c ON c.id = cl.chapter_id JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id WHERE c.language_id = :languageId AND (l1.text ILIKE :like OR l2.text ILIKE :like) ORDER BY l2.text ASC, l1.text ASC, c.title ASC LIMIT 200 `, { replacements: { languageId: access.id, like, }, type: sequelize.QueryTypes.SELECT, } ); return { languageId: access.id, results: rows }; } async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) { const user = await this._getUserByHashedId(hashedUserId); const ch = await this._getChapterAccess(user.id, chapterId); if (!ch.isOwner) { const err = new Error('Only owner can add vocab'); err.status = 403; throw err; } const learningText = typeof learning === 'string' ? learning.trim() : ''; const referenceText = typeof reference === 'string' ? reference.trim() : ''; if (!learningText || !referenceText) { const err = new Error('Invalid vocab'); err.status = 400; throw err; } const learningNorm = this._normalizeLexeme(learningText); const referenceNorm = this._normalizeLexeme(referenceText); // Transaktion: Lexeme upserten + Zuordnung setzen return await sequelize.transaction(async (t) => { const [learningLex] = await sequelize.query( ` INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id) VALUES (:languageId, :text, :normalized, :userId) ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text RETURNING id `, { replacements: { languageId: ch.languageId, text: learningText, normalized: learningNorm, userId: user.id }, type: sequelize.QueryTypes.SELECT, transaction: t, } ); const [referenceLex] = await sequelize.query( ` INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id) VALUES (:languageId, :text, :normalized, :userId) ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text RETURNING id `, { replacements: { languageId: ch.languageId, text: referenceText, normalized: referenceNorm, userId: user.id }, type: sequelize.QueryTypes.SELECT, transaction: t, } ); const [mapping] = await sequelize.query( ` INSERT INTO community.vocab_chapter_lexeme (chapter_id, learning_lexeme_id, reference_lexeme_id, created_by_user_id) VALUES (:chapterId, :learningId, :referenceId, :userId) ON CONFLICT (chapter_id, learning_lexeme_id, reference_lexeme_id) DO NOTHING RETURNING id `, { replacements: { chapterId: ch.id, learningId: learningLex.id, referenceId: referenceLex.id, userId: user.id, }, type: sequelize.QueryTypes.SELECT, transaction: t, } ); return { created: Boolean(mapping?.id) }; }); } // ========== COURSE METHODS ========== async createCourse(hashedUserId, { title, description, languageId, nativeLanguageId, 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), nativeLanguageId: nativeLanguageId ? Number(nativeLanguageId) : null, difficultyLevel: Number(difficultyLevel) || 1, isPublic: Boolean(isPublic), shareCode }); return course.get({ plain: true }); } async getCourses(hashedUserId, { includePublic = true, includeOwn = true, languageId, nativeLanguageId, search } = {}) { const user = await this._getUserByHashedId(hashedUserId); // Konvertiere String-Parameter zu Booleans const includePublicBool = includePublic === 'true' || includePublic === true; const includeOwnBool = includeOwn === 'true' || includeOwn === true; const where = {}; const andConditions = []; // Zugriffsbedingungen if (includeOwnBool && includePublicBool) { andConditions.push({ [Op.or]: [ { ownerUserId: user.id }, { isPublic: true } ] }); } else if (includeOwnBool) { where.ownerUserId = user.id; } else if (includePublicBool) { where.isPublic = true; } // Filter nach Zielsprache (die zu lernende Sprache) if (languageId) { where.languageId = Number(languageId); } // Filter nach Muttersprache (die Sprache des Lerners) // Wenn nativeLanguageId nicht gesetzt ist (undefined), zeige alle Kurse (kein Filter) // Wenn nativeLanguageId === null oder 'null' (aus Frontend), zeige alle Kurse (kein Filter) // Wenn nativeLanguageId eine Zahl ist, zeige nur Kurse für diese Muttersprache if (nativeLanguageId !== undefined && nativeLanguageId !== null && nativeLanguageId !== 'null') { where.nativeLanguageId = Number(nativeLanguageId); } // Wenn nativeLanguageId null/undefined/'null' ist, wird kein Filter angewendet = alle Kurse // Suche nach Titel oder Beschreibung if (search && search.trim()) { const searchTerm = `%${search.trim()}%`; andConditions.push({ [Op.or]: [ { title: { [Op.iLike]: searchTerm } }, { description: { [Op.iLike]: searchTerm } } ] }); } // Kombiniere alle AND-Bedingungen // Wenn sowohl andConditions als auch direkte where-Eigenschaften existieren, // müssen sie kombiniert werden const directWhereProps = Object.keys(where).filter(key => key !== Op.and && key !== Op.or); // Debug: Prüfe, ob direkte Eigenschaften gesetzt wurden console.log(`[getCourses] Debug - Vor Kombination:`, { directWhereProps, whereKeys: Object.keys(where), andConditionsLength: andConditions.length, languageId, nativeLanguageId }); if (andConditions.length > 0) { // Wenn where bereits direkte Eigenschaften hat, füge sie zu andConditions hinzu if (directWhereProps.length > 0) { const directWhere = {}; for (const key of directWhereProps) { directWhere[key] = where[key]; delete where[key]; } andConditions.push(directWhere); console.log(`[getCourses] Debug - Direkte Eigenschaften zu andConditions hinzugefügt:`, directWhere); } where[Op.and] = andConditions; console.log(`[getCourses] Debug - Finale andConditions:`, JSON.stringify(andConditions, null, 2)); } // Wenn nur direkte Eigenschaften existieren (undConditions.length === 0), // bleiben sie in where (nichts zu tun, sie sind bereits dort) console.log(`[getCourses] Debug - Finale WHERE-Klausel:`, JSON.stringify(where, null, 2)); const courses = await VocabCourse.findAll({ where, order: [['createdAt', 'DESC']] }); // Debug-Logging (kann später entfernt werden) console.log(`[getCourses] Gefunden: ${courses.length} Kurse`, { userId: user.id, languageId, nativeLanguageId, search, whereBefore: JSON.stringify(where, null, 2), includePublic: includePublicBool, includeOwn: includeOwnBool, andConditionsLength: andConditions.length, directWherePropsBefore: Object.keys(where).filter(key => key !== Op.and && key !== Op.or), whereAfter: JSON.stringify(where, null, 2) }); const coursesData = courses.map(c => c.get({ plain: true })); // Lade Sprachnamen für alle Kurse const languageIds = [...new Set(coursesData.map(c => c.languageId))]; if (languageIds.length > 0) { const [languages] = await sequelize.query( `SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`, { replacements: { languageIds }, type: sequelize.QueryTypes.SELECT } ); const languageMap = new Map(languages.map(l => [l.id, l.name])); coursesData.forEach(c => { c.languageName = languageMap.get(c.languageId) || null; }); } return coursesData; } async getCourseByShareCode(hashedUserId, shareCode) { const user = await this._getUserByHashedId(hashedUserId); const code = typeof shareCode === 'string' ? shareCode.trim() : ''; if (!code || code.length < 6 || code.length > 128) { const err = new Error('Invalid share code'); err.status = 400; throw err; } const course = await VocabCourse.findOne({ where: { shareCode: code } }); if (!course) { const err = new Error('Course not found'); err.status = 404; throw err; } // Prüfe Zugriff (öffentlich oder Besitzer) if (course.ownerUserId !== user.id && !course.isPublic) { const err = new Error('Course is not public'); err.status = 403; throw err; } return course.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, languageId, nativeLanguageId, 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 (languageId !== undefined) updates.languageId = Number(languageId); if (nativeLanguageId !== undefined) updates.nativeLanguageId = nativeLanguageId ? Number(nativeLanguageId) : null; 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 }; } }