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 UserParamType from '../models/type/user_param.js'; import UserParam from '../models/community/user_param.js'; import { sequelize } from '../utils/sequelize.js'; import { notifyUser } from '../utils/socket.js'; import { Op } from 'sequelize'; import { BISAYA_PHASE1_DIDACTICS } from '../scripts/bisaya-course-phase1.js'; export default class VocabService { _normalizeIsoDate(value) { if (!value) { return ''; } const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) { return ''; } return date.toISOString(); } _clampInteger(value, { min = 0, max = 100000, fallback = 0 } = {}) { const numeric = Number(value); if (!Number.isFinite(numeric)) { return fallback; } return Math.max(min, Math.min(max, Math.trunc(numeric))); } /** * Wörterbuch-API: page ab 1, pageSize max. 100, Standard 25. */ _parseDictionaryPaging(query = {}) { const page = Math.max(1, this._clampInteger(query?.page, { min: 1, max: 1_000_000, fallback: 1 })); const pageSize = this._clampInteger(query?.pageSize, { min: 1, max: 100, fallback: 25 }); return { page, pageSize }; } _sanitizeShortString(value, maxLength = 400) { const text = String(value ?? '').trim(); if (!text) { return ''; } return text.slice(0, maxLength); } _sanitizeStringArray(value, { maxItems = 12, maxLength = 400, keepEmpty = false } = {}) { if (!Array.isArray(value)) { return []; } return value .slice(0, maxItems) .map((entry) => this._sanitizeShortString(entry, maxLength)) .filter((entry) => keepEmpty || Boolean(entry)); } _sanitizeExerciseAnswers(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } const sanitized = {}; const synMcId = /^syn-\d+-\d+-l2r$/; Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => { const idStr = String(exerciseId); if (!/^\d+$/.test(idStr) && !synMcId.test(idStr)) { return; } if (Array.isArray(answer)) { sanitized[idStr] = this._sanitizeStringArray(answer, { maxItems: 12, maxLength: 200, keepEmpty: true }); return; } if (typeof answer === 'string') { sanitized[idStr] = this._sanitizeShortString(answer, 200); return; } if (typeof answer === 'number' && Number.isFinite(answer)) { sanitized[idStr] = Math.trunc(answer); } }); return sanitized; } _sanitizeExerciseResults(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } const synMcId = /^syn-\d+-\d+-l2r$/; const sanitized = {}; Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => { const idStr = String(exerciseId); if ((!/^\d+$/.test(idStr) && !synMcId.test(idStr)) || !result || typeof result !== 'object' || Array.isArray(result)) { return; } sanitized[idStr] = { correct: Boolean(result.correct), correctAnswer: this._sanitizeShortString(result.correctAnswer, 400), alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }), explanation: this._sanitizeShortString(result.explanation, 1200) }; }); return sanitized; } _sanitizeVocabTrainerStats(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } const sanitized = {}; Object.entries(value).slice(0, 400).forEach(([key, stats]) => { const safeKey = this._sanitizeShortString(key, 200); if (!safeKey || !stats || typeof stats !== 'object' || Array.isArray(stats)) { return; } sanitized[safeKey] = { attempts: this._clampInteger(stats.attempts, { max: 5000 }), correct: this._clampInteger(stats.correct, { max: 5000 }), wrong: this._clampInteger(stats.wrong, { max: 5000 }) }; }); return sanitized; } _sanitizeRepeatQueue(value) { if (!Array.isArray(value)) { return []; } return value .slice(0, 100) .map((entry) => ({ key: this._sanitizeShortString(entry?.key, 200), dueAfter: this._clampInteger(entry?.dueAfter, { min: 0, max: 50 }), stageIndex: this._clampInteger(entry?.stageIndex, { min: 0, max: 10 }) })) .filter((entry) => entry.key); } _sanitizeLessonState(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } const knownKeys = [ 'version', 'updatedAt', 'activeTab', 'exercisePreparationCompleted', 'lessonPrepStage', 'lessonPrepIndex', 'vocabTrainerActive', 'vocabTrainerMode', 'vocabTrainerAutoSwitchedToTyping', 'vocabTrainerCorrect', 'vocabTrainerWrong', 'vocabTrainerTotalAttempts', 'vocabTrainerCurrentAttempts', 'vocabTrainerReviewAttempts', 'vocabTrainerStats', 'vocabTrainerRepeatQueue', 'exerciseAnswers', 'exerciseResults', 'exerciseRetryPending', 'exerciseRetryPendingSinceAttempts', 'reviewStage', 'reviewNextDueAt', 'reviewLastReviewedAt' ]; const hasKnownState = knownKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key)); if (!hasKnownState) { return {}; } const activeTab = value.activeTab === 'exercises' ? 'exercises' : 'learn'; const vocabTrainerMode = value.vocabTrainerMode === 'typing' ? 'typing' : 'multiple_choice'; return { version: this._clampInteger(value.version, { min: 1, max: 1000, fallback: 1 }), updatedAt: this._sanitizeShortString(value.updatedAt || new Date().toISOString(), 64), activeTab, exercisePreparationCompleted: Boolean(value.exercisePreparationCompleted), lessonPrepStage: this._clampInteger(value.lessonPrepStage, { min: 0, max: 2 }), lessonPrepIndex: this._clampInteger(value.lessonPrepIndex, { min: 0, max: 500 }), vocabTrainerActive: Boolean(value.vocabTrainerActive), vocabTrainerMode, vocabTrainerAutoSwitchedToTyping: Boolean(value.vocabTrainerAutoSwitchedToTyping), vocabTrainerCorrect: this._clampInteger(value.vocabTrainerCorrect, { max: 5000 }), vocabTrainerWrong: this._clampInteger(value.vocabTrainerWrong, { max: 5000 }), vocabTrainerTotalAttempts: this._clampInteger(value.vocabTrainerTotalAttempts, { max: 10000 }), vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }), vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }), vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats), vocabTrainerRepeatQueue: this._sanitizeRepeatQueue(value.vocabTrainerRepeatQueue), exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers), exerciseResults: this._sanitizeExerciseResults(value.exerciseResults), exerciseRetryPending: Boolean(value.exerciseRetryPending), exerciseRetryPendingSinceAttempts: this._clampInteger(value.exerciseRetryPendingSinceAttempts, { max: 10000 }), reviewStage: this._clampInteger(value.reviewStage, { min: 0, max: 3 }), reviewNextDueAt: this._normalizeIsoDate(value.reviewNextDueAt), reviewLastReviewedAt: this._normalizeIsoDate(value.reviewLastReviewedAt) }; } _supportsScheduledReview(lessonData = null) { const lessonType = String(lessonData?.lessonType || '').toLowerCase(); const didacticMode = String(lessonData?.didacticMode || '').toLowerCase(); if (lessonType === 'culture' || lessonType === 'review' || lessonType === 'vocab_review') { return false; } if (didacticMode === 'intensive_review' || didacticMode === 'checkpoint') { return false; } return true; } _removeManagedReviewState(lessonState = {}) { const nextState = { ...(lessonState || {}) }; delete nextState.reviewStage; delete nextState.reviewNextDueAt; delete nextState.reviewLastReviewedAt; return nextState; } _applyScheduledReviewState(lessonState = {}, { previousCompleted = false, nextCompleted = false, shouldAdvanceReview = false, lessonData = null, now = new Date() } = {}) { const baseState = this._sanitizeLessonState(lessonState); if (!this._supportsScheduledReview(lessonData) || !nextCompleted) { return this._removeManagedReviewState(baseState); } const reviewIntervalsDays = [1, 3, 7]; const currentStage = this._clampInteger(baseState.reviewStage, { min: 0, max: reviewIntervalsDays.length }); const dueAtIso = this._normalizeIsoDate(baseState.reviewNextDueAt); const dueAt = dueAtIso ? new Date(dueAtIso) : null; const reviewLastReviewedAt = this._normalizeIsoDate(baseState.reviewLastReviewedAt); const nowIso = this._normalizeIsoDate(now); const nextState = { ...baseState, reviewStage: currentStage, reviewNextDueAt: dueAtIso, reviewLastReviewedAt }; if (!previousCompleted && shouldAdvanceReview) { nextState.reviewStage = 0; nextState.reviewLastReviewedAt = nowIso; nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[0] * 24 * 60 * 60 * 1000)); return nextState; } if (!dueAtIso && currentStage < reviewIntervalsDays.length) { nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[currentStage] * 24 * 60 * 60 * 1000)); } if (!shouldAdvanceReview || !dueAt || Number.isNaN(dueAt.getTime()) || now.getTime() < dueAt.getTime()) { return nextState; } const nextStage = Math.min(reviewIntervalsDays.length, currentStage + 1); nextState.reviewStage = nextStage; nextState.reviewLastReviewedAt = nowIso; nextState.reviewNextDueAt = nextStage >= reviewIntervalsDays.length ? '' : this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[nextStage] * 24 * 60 * 60 * 1000)); return nextState; } _serializeLessonProgress(progress, lessonData = null) { if (!progress) { return null; } const plainProgress = progress.get ? progress.get({ plain: true }) : { ...progress }; const targetScore = lessonData?.targetScorePercent || plainProgress.lesson?.targetScorePercent || 80; const hasReachedTarget = (plainProgress.score || 0) >= targetScore; const lessonState = this._sanitizeLessonState(plainProgress.lessonState); const reviewStage = this._clampInteger(lessonState.reviewStage, { min: 0, max: 3 }); const reviewNextDueAt = this._normalizeIsoDate(lessonState.reviewNextDueAt); const reviewDue = Boolean(reviewNextDueAt && reviewStage < 3 && new Date(reviewNextDueAt).getTime() <= Date.now()); return { ...plainProgress, lessonId: Number(plainProgress.lessonId), lessonNumber: lessonData?.lessonNumber ?? plainProgress.lesson?.lessonNumber ?? null, lessonState, targetScore, hasReachedTarget, needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget), reviewStage, reviewNextDueAt, reviewDue, reviewCompleted: reviewStage >= 3 }; } 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; } async _attachLanguageNamesToCourseRows(coursesData) { if (!coursesData.length) { return; } 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 } ); if (Array.isArray(languages)) { const languageMap = new Map(languages.map((l) => [l.id, l.name])); coursesData.forEach((c) => { c.languageName = languageMap.get(c.languageId) || null; }); } } const nativeLanguageIds = [...new Set(coursesData.map((c) => c.nativeLanguageId).filter((id) => id !== null))]; if (nativeLanguageIds.length > 0) { const nativeLanguages = await sequelize.query( `SELECT id, name FROM community.vocab_language WHERE id IN (:nativeLanguageIds)`, { replacements: { nativeLanguageIds }, type: sequelize.QueryTypes.SELECT } ); if (Array.isArray(nativeLanguages)) { const nativeLanguageMap = new Map(nativeLanguages.map((l) => [l.id, l.name])); coursesData.forEach((c) => { c.nativeLanguageName = c.nativeLanguageId ? nativeLanguageMap.get(c.nativeLanguageId) || null : null; }); } } } async _getUserLlmConfig(userId) { const [settingsType, apiKeyType] = await Promise.all([ UserParamType.findOne({ where: { description: 'llm_settings' } }), UserParamType.findOne({ where: { description: 'llm_api_key' } }) ]); if (!settingsType || !apiKeyType) { return { enabled: false, baseUrl: '', model: 'gpt-4o-mini', hasKey: false, apiKey: null, configured: false }; } const [settingsRow, keyRow] = await Promise.all([ UserParam.findOne({ where: { userId, paramTypeId: settingsType.id } }), UserParam.findOne({ where: { userId, paramTypeId: apiKeyType.id } }) ]); let parsed = {}; if (settingsRow?.value) { try { parsed = JSON.parse(settingsRow.value); } catch { parsed = {}; } } const decryptedKey = keyRow?.value ? String(keyRow.value).trim() : null; const hasKey = Boolean(decryptedKey && String(decryptedKey).trim()); const enabled = parsed.enabled !== false; const baseUrl = String(parsed.baseUrl || '').trim(); return { enabled, baseUrl, model: String(parsed.model || 'gpt-4o-mini').trim() || 'gpt-4o-mini', hasKey, apiKey: hasKey ? decryptedKey : null, configured: enabled && (hasKey || Boolean(baseUrl)) }; } _sanitizeAssistantHistory(history) { if (!Array.isArray(history)) { return []; } return history .slice(-8) .map((entry) => ({ role: entry?.role === 'assistant' ? 'assistant' : 'user', content: String(entry?.content || '').trim() })) .filter((entry) => entry.content); } _buildLessonAssistantSystemPrompt(lesson, mode = 'practice') { const didactics = lesson?.didactics || {}; const learningGoals = Array.isArray(didactics.learningGoals) ? didactics.learningGoals : []; const corePatterns = Array.isArray(didactics.corePatterns) ? didactics.corePatterns : []; const speakingPrompts = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts : []; const practicalTasks = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks : []; const modeDirectives = { explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.', practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.', correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.' }; return [ 'Du bist ein didaktischer Sprachassistent innerhalb eines Sprachkurses.', 'Antworte auf Deutsch, aber verwende die Zielsprache der Lektion aktiv in Beispielen und Mini-Dialogen.', modeDirectives[mode] || modeDirectives.practice, 'Halte Antworten kompakt, praxisnah und auf diese Lektion fokussiert.', `Kurs: ${lesson?.course?.title || 'Unbekannter Kurs'}`, `Lektion: ${lesson?.title || 'Unbekannte Lektion'}`, lesson?.description ? `Beschreibung: ${lesson.description}` : '', learningGoals.length ? `Lernziele: ${learningGoals.join(' | ')}` : '', corePatterns.length ? `Kernmuster: ${corePatterns.map((p) => { const n = this._normalizeCorePatternEntry(p); if (!n) return ''; return n.gloss ? `${n.target} (${n.gloss})` : n.target; }).filter(Boolean).join(' | ')}` : '', speakingPrompts.length ? `Sprechaufträge: ${speakingPrompts.map((item) => item.prompt || item.title || '').filter(Boolean).join(' | ')}` : '', practicalTasks.length ? `Praxisaufgaben: ${practicalTasks.map((item) => item.text || item.title || '').filter(Boolean).join(' | ')}` : '', 'Wenn der Nutzer eine Formulierung versucht, korrigiere sie präzise und gib eine verbesserte Version.' ].filter(Boolean).join('\n'); } _extractAssistantContent(responseData) { const rawContent = responseData?.choices?.[0]?.message?.content; if (typeof rawContent === 'string') { return rawContent.trim(); } if (Array.isArray(rawContent)) { return rawContent .map((item) => { if (typeof item === 'string') return item; if (item?.type === 'text') return item.text || ''; return ''; }) .join('\n') .trim(); } return ''; } _normalizeLexeme(text) { return String(text || '') .trim() .toLowerCase() .replace(/\s+/g, ' '); } _normalizeTextAnswer(text) { const normalized = String(text || '') .trim() .toLowerCase() .normalize('NFKC') .replace(/[\p{P}\p{S}]+/gu, ' ') .replace(/\s+/g, ' ') .trim(); return normalized.replace(/\s+/g, ''); } _parseExercisePayload(value) { if (!value) return {}; if (typeof value === 'string') { try { return JSON.parse(value); } catch { return {}; } } if (typeof value === 'object') { return value; } return {}; } _extractTrainerVocabsFromExercises(exercises = []) { const vocabMap = new Map(); exercises.forEach((exercise) => { try { const qData = this._parseExercisePayload(exercise.questionData); const aData = this._parseExercisePayload(exercise.answerData); const exerciseType = exercise.exerciseType?.name || qData.type || ''; if (exerciseType === 'multiple_choice') { const options = Array.isArray(qData.options) ? qData.options : []; const correctAnswer = Array.isArray(aData.correctAnswer) ? options[aData.correctAnswer[0]] : options[aData.correctAnswer ?? aData.correct ?? 0]; const question = String(qData.question || qData.text || ''); let match = question.match(/Wie sagt man ['"]([^'"]+)['"]/i); if (match && match[1] && correctAnswer && match[1].trim() !== String(correctAnswer).trim()) { vocabMap.set(`${match[1]}-${correctAnswer}`, { learning: match[1], reference: String(correctAnswer) }); return; } match = question.match(/Was bedeutet ['"]([^'"]+)['"]/i); if (match && match[1] && correctAnswer && match[1].trim() !== String(correctAnswer).trim()) { vocabMap.set(`${correctAnswer}-${match[1]}`, { learning: String(correctAnswer), reference: match[1] }); } return; } if (exerciseType === 'gap_fill') { const answers = Array.isArray(aData.answers) ? aData.answers : (aData.correct ? (Array.isArray(aData.correct) ? aData.correct : [aData.correct]) : []); const text = String(qData.text || ''); const nativeWords = Array.from(text.matchAll(/\(([^)]+)\)/g), (m) => String(m[1] || '').trim()); if (!answers.length || !nativeWords.length) { return; } answers.forEach((answer, index) => { const nativeWord = nativeWords[index]; const normalizedAnswer = String(answer || '').trim(); if (!nativeWord || !normalizedAnswer || nativeWord === normalizedAnswer) { return; } vocabMap.set(`${nativeWord}-${normalizedAnswer}`, { learning: nativeWord, reference: normalizedAnswer }); }); } } catch (error) { console.warn('Fehler beim Extrahieren von Trainer-Vokabeln:', error); } }); return Array.from(vocabMap.values()); } _extractTrainerVocabsFromLessonDidactics(lesson) { const vocabMap = new Map(); const speakingPrompts = Array.isArray(lesson?.speakingPrompts) ? lesson.speakingPrompts : []; const practicalTasks = Array.isArray(lesson?.practicalTasks) ? lesson.practicalTasks : []; const corePatterns = Array.isArray(lesson?.corePatterns) ? lesson.corePatterns : []; speakingPrompts.forEach((prompt, index) => { const learning = String(prompt?.prompt || prompt?.title || '').trim(); const refEntry = corePatterns[index] ?? corePatterns[0]; const reference = String(prompt?.cue || this._corePatternTarget(refEntry) || '').trim(); if (!learning || !reference || learning === reference) return; vocabMap.set(`${learning}-${reference}`, { learning, reference }); }); practicalTasks.forEach((task, index) => { const learning = String(task?.text || task?.title || '').trim(); const refEntry = corePatterns[index] ?? corePatterns[0]; const reference = String(this._corePatternTarget(refEntry) || '').trim(); if (!learning || !reference || learning === reference) return; vocabMap.set(`${learning}-${reference}`, { learning, reference }); }); return Array.from(vocabMap.values()); } _normalizeStringList(value) { if (!value) return []; if (Array.isArray(value)) { return value .map((entry) => String(entry || '').trim()) .filter(Boolean); } if (typeof value === 'string') { return value .split(/\r?\n|;/) .map((entry) => entry.trim()) .filter(Boolean); } return []; } /** * Kernmuster: Zielsprachen-Phrase + optionale Glossierung (z. B. Deutsch). * Unterstützt Legacy-Strings, "Phrase|Gloss" und Objekte { target, gloss } / { ceb, de }. */ _normalizeCorePatternEntry(entry) { if (entry === null || entry === undefined || entry === '') { return null; } if (typeof entry === 'object' && !Array.isArray(entry)) { const target = String(entry.target ?? entry.ceb ?? entry.phrase ?? '').trim(); const gloss = String(entry.gloss ?? entry.de ?? entry.translation ?? '').trim(); if (!target) return null; return { target, gloss }; } const s = String(entry).trim(); if (!s) return null; const pipe = s.indexOf('|'); if (pipe !== -1) { const target = s.slice(0, pipe).trim(); const gloss = s.slice(pipe + 1).trim(); if (!target) return null; return { target, gloss }; } return { target: s, gloss: '' }; } _normalizeCorePatternList(value) { if (!value) return []; const raw = Array.isArray(value) ? value : (typeof value === 'string' ? value.split(/\r?\n|;/).map((entry) => entry.trim()).filter(Boolean) : []); return raw .map((entry) => this._normalizeCorePatternEntry(entry)) .filter(Boolean); } _corePatternTarget(entry) { const n = this._normalizeCorePatternEntry(entry); return n ? n.target : ''; } _enrichCorePatternsWithGloss(corePatterns = [], extractedVocabs = []) { const glossByReference = new Map(); extractedVocabs.forEach((item) => { const reference = this._normalizeLexeme(item?.reference); const learning = String(item?.learning || '').trim(); if (!reference || !learning) { return; } if (!glossByReference.has(reference)) { glossByReference.set(reference, learning); } }); return corePatterns .map((entry) => this._normalizeCorePatternEntry(entry)) .filter(Boolean) .map((entry) => { if (entry.gloss) { return entry; } const gloss = glossByReference.get(this._normalizeLexeme(entry.target)) || ''; return gloss ? { ...entry, gloss } : entry; }); } _mergeCorePatternGlosses(primaryPatterns = [], fallbackPatterns = []) { const fallbackByTarget = new Map( fallbackPatterns .map((entry) => this._normalizeCorePatternEntry(entry)) .filter(Boolean) .map((entry) => [this._normalizeLexeme(entry.target), entry.gloss || '']) ); return primaryPatterns.map((entry) => { const normalized = this._normalizeCorePatternEntry(entry); if (!normalized) { return null; } if (normalized.gloss) { return normalized; } const gloss = fallbackByTarget.get(this._normalizeLexeme(normalized.target)) || ''; return gloss ? { ...normalized, gloss } : normalized; }).filter(Boolean); } _normalizeStructuredList(value, keys = ['title', 'text']) { if (!value) return []; if (Array.isArray(value)) { return value .map((entry) => { if (typeof entry === 'string') { return { title: '', text: entry.trim() }; } if (!entry || typeof entry !== 'object') return null; const normalized = {}; keys.forEach((key) => { if (entry[key] !== undefined && entry[key] !== null) { normalized[key] = String(entry[key]).trim(); } }); return Object.keys(normalized).length > 0 ? normalized : null; }) .filter(Boolean); } return []; } _normalizeOptionalInteger(value) { if (value === undefined || value === null || value === '') { return null; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } _normalizeOptionalString(value) { if (value === undefined || value === null) { return null; } const trimmed = String(value).trim(); return trimmed || null; } _inferLessonPhaseLabel(plainLesson) { if (plainLesson.phaseLabel) { return plainLesson.phaseLabel; } const weekNumber = Number(plainLesson.weekNumber) || 0; if (weekNumber > 0 && weekNumber <= 2) { return 'quickstart'; } if (weekNumber === 3) { return 'daily_life'; } if (weekNumber >= 4) { return 'stabilization'; } return 'quickstart'; } _inferLessonDidacticMode(plainLesson) { const lessonType = String(plainLesson.lessonType || '').toLowerCase(); const title = String(plainLesson.title || '').toLowerCase(); const storedMode = String(plainLesson.didacticMode || '').trim(); const isContrastTraining = lessonType === 'grammar' && [ 'kontrast', 'fehlertraining', ' / ', 'nicht / kein', 'der / die / das', 'wo / wohin', 'du / sie', 'haben / sein', 'ich bin / ich habe', 'ich bin / ich heiße / ich komme' ].some((marker) => title.includes(marker)); if (storedMode && storedMode !== 'pattern_drill') { return storedMode; } if (isContrastTraining) { return 'contrast_training'; } if (storedMode) { return storedMode; } if (title.includes('abschluss') || title.includes('prüfung') || title.includes('test')) { return 'checkpoint'; } if (plainLesson.isIntensiveReview || lessonType === 'review' || lessonType === 'vocab_review' || title.includes('wiederholung')) { return 'intensive_review'; } if (lessonType === 'grammar') { return 'pattern_drill'; } if (lessonType === 'conversation' || lessonType === 'dialogue' || lessonType === 'phrases' || lessonType === 'survival') { return 'guided_dialogue'; } if (lessonType === 'culture') { return 'real_life_scenario'; } return 'core_input'; } _inferLessonDifficultyWeight(plainLesson, didacticMode) { if (plainLesson.difficultyWeight != null) { return plainLesson.difficultyWeight; } switch (didacticMode) { case 'contrast_training': case 'pattern_drill': return 3; case 'guided_dialogue': case 'real_life_scenario': return 2; case 'intensive_review': case 'checkpoint': return 2; default: return 1; } } _inferLessonNewUnitTarget(plainLesson, didacticMode) { if (plainLesson.newUnitTarget != null) { return plainLesson.newUnitTarget; } switch (didacticMode) { case 'contrast_training': return 3; case 'core_input': return 8; case 'guided_dialogue': return 5; case 'pattern_drill': return 4; case 'real_life_scenario': return 3; case 'checkpoint': return 2; case 'intensive_review': return 1; default: return 4; } } _inferLessonReviewWeight(plainLesson, didacticMode) { if (plainLesson.reviewWeight != null) { return plainLesson.reviewWeight; } switch (didacticMode) { case 'intensive_review': return 90; case 'checkpoint': return 70; case 'contrast_training': return 70; case 'pattern_drill': return 55; case 'real_life_scenario': return 45; case 'guided_dialogue': return 40; default: return 30; } } _inferLessonBlockNumber(plainLesson) { if (plainLesson.blockNumber != null) { return plainLesson.blockNumber; } const weekNumber = Number(plainLesson.weekNumber) || 1; return Math.max(1, Math.ceil(weekNumber / 2)); } _buildLessonPedagogy(plainLesson) { const didacticMode = this._inferLessonDidacticMode(plainLesson); const phaseLabel = this._inferLessonPhaseLabel(plainLesson); const isIntensiveReview = plainLesson.isIntensiveReview != null ? Boolean(plainLesson.isIntensiveReview) : didacticMode === 'intensive_review'; return { didacticMode, phaseLabel, blockNumber: this._inferLessonBlockNumber(plainLesson), difficultyWeight: this._inferLessonDifficultyWeight(plainLesson, didacticMode), newUnitTarget: this._inferLessonNewUnitTarget(plainLesson, didacticMode), reviewWeight: this._inferLessonReviewWeight(plainLesson, didacticMode), isIntensiveReview }; } _buildLessonDidactics(plainLesson) { const grammarExercises = Array.isArray(plainLesson.grammarExercises) ? plainLesson.grammarExercises : []; const grammarExplanations = []; const patterns = []; const speakingPrompts = []; grammarExercises.forEach((exercise) => { const questionData = typeof exercise.questionData === 'string' ? JSON.parse(exercise.questionData) : (exercise.questionData || {}); if (exercise.explanation) { grammarExplanations.push({ title: exercise.title || '', text: exercise.explanation }); } const patternCandidates = [ questionData.pattern, questionData.exampleSentence, questionData.modelAnswer, questionData.promptSentence ].filter(Boolean); patternCandidates.forEach((candidate) => { patterns.push(String(candidate).trim()); }); if (questionData.type === 'reading_aloud' || questionData.type === 'speaking_from_memory') { speakingPrompts.push({ title: exercise.title || '', prompt: questionData.question || questionData.text || '', cue: questionData.expectedText || '', keywords: Array.isArray(questionData.keywords) ? questionData.keywords : [] }); } }); const uniqueGrammarExplanations = grammarExplanations.filter((item, index, list) => { const signature = `${item.title}::${item.text}`; return list.findIndex((entry) => `${entry.title}::${entry.text}` === signature) === index; }); const uniquePatterns = [...new Set(patterns.map((item) => String(item || '').trim()).filter(Boolean))]; const learningGoals = this._normalizeStringList(plainLesson.learningGoals); const extractedTrainerVocabs = this._extractTrainerVocabsFromExercises(grammarExercises); const phase1FallbackCorePatterns = BISAYA_PHASE1_DIDACTICS[plainLesson.title]?.corePatterns || []; const corePatterns = this._mergeCorePatternGlosses( this._enrichCorePatternsWithGloss( this._normalizeCorePatternList(plainLesson.corePatterns), extractedTrainerVocabs ), phase1FallbackCorePatterns ); const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']); const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']); const practicalTasks = this._normalizeStructuredList(plainLesson.practicalTasks, ['title', 'text']); return { learningGoals: learningGoals.length > 0 ? learningGoals : [ 'Die Schlüsselausdrücke der Lektion verstehen und wiedererkennen.', 'Ein bis zwei Satzmuster aktiv anwenden.', 'Kurze Sätze oder Mini-Dialoge zum Thema selbst bilden.' ], corePatterns: corePatterns.length > 0 ? corePatterns : this._mergeCorePatternGlosses( this._enrichCorePatternsWithGloss( uniquePatterns.slice(0, 5).map((s) => ({ target: String(s || '').trim(), gloss: '' })).filter((p) => p.target), extractedTrainerVocabs ), phase1FallbackCorePatterns ), grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4), speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4), practicalTasks: practicalTasks.length > 0 ? practicalTasks : [ { title: 'Mini-Anwendung', text: 'Formuliere zwei bis drei eigene Sätze oder einen kurzen Dialog mit dem Muster dieser Lektion.' } ] }; } 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 listAllLanguages() { // Gibt alle verfügbaren Sprachen zurück (für Kursliste) const rows = await sequelize.query( ` SELECT id, name FROM community.vocab_language ORDER BY name ASC `, { 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 getLessonVocabPool(hashedUserId, lessonId) { const user = await this._getUserByHashedId(hashedUserId); const lesson = await VocabCourseLesson.findByPk(lessonId, { include: [ { model: VocabCourse, as: 'course' }, { model: VocabGrammarExercise, as: 'grammarExercises', include: [ { model: VocabGrammarExerciseType, as: 'exerciseType' } ], required: false } ] }); if (!lesson) { const err = new Error('Lesson not found'); err.status = 404; throw err; } if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) { const err = new Error('Access denied'); err.status = 403; throw err; } const progress = await VocabCourseProgress.findOne({ where: { userId: user.id, lessonId: lesson.id } }); if (lesson.course.ownerUserId !== user.id && !progress?.completed) { const err = new Error('Lesson must be completed first'); err.status = 403; throw err; } const extractedFromExercises = this._extractTrainerVocabsFromExercises( (lesson.grammarExercises || []).map((exercise) => exercise.get({ plain: true })) ); const vocabs = extractedFromExercises.length > 0 ? extractedFromExercises : this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true })); return { lesson: { id: lesson.id, title: lesson.title, courseId: lesson.courseId, courseTitle: lesson.course.title }, vocabs }; } async getCompletedLessonVocabPool(hashedUserId, courseId, untilLessonId = null) { 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 && !course.isPublic) { const err = new Error('Access denied'); err.status = 403; throw err; } let maxLessonNumber = null; if (untilLessonId) { const untilLesson = await VocabCourseLesson.findOne({ where: { id: untilLessonId, courseId: course.id }, attributes: ['lessonNumber'] }); if (!untilLesson) { const err = new Error('Lesson not found'); err.status = 404; throw err; } maxLessonNumber = untilLesson.lessonNumber; } const completedProgress = await VocabCourseProgress.findAll({ where: { userId: user.id, courseId: course.id, completed: true }, attributes: ['lessonId'] }); const completedLessonIds = completedProgress.map((entry) => entry.lessonId); if (completedLessonIds.length === 0) { return { courseId: course.id, vocabs: [] }; } const lessonWhere = { id: { [Op.in]: completedLessonIds }, courseId: course.id }; if (maxLessonNumber != null) { lessonWhere.lessonNumber = { [Op.lte]: maxLessonNumber }; } const lessons = await VocabCourseLesson.findAll({ where: lessonWhere, attributes: ['id', 'speakingPrompts', 'practicalTasks', 'corePatterns'], order: [['lessonNumber', 'ASC']] }); const lessonIds = lessons.map((lesson) => lesson.id); if (!lessonIds.length) { return { courseId: course.id, vocabs: [] }; } const exercises = await VocabGrammarExercise.findAll({ where: { lessonId: { [Op.in]: lessonIds } }, include: [ { model: VocabGrammarExerciseType, as: 'exerciseType' } ], order: [['lessonId', 'ASC'], ['exerciseNumber', 'ASC']] }); const extractedFromExercises = this._extractTrainerVocabsFromExercises(exercises.map((exercise) => exercise.get({ plain: true }))); const fallbackVocabs = lessons.flatMap((lesson) => this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true })) ); const mergedVocabs = new Map(); [...extractedFromExercises, ...fallbackVocabs].forEach((entry) => { if (!entry?.learning || !entry?.reference) return; mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry); }); return { courseId: course.id, vocabs: Array.from(mergedVocabs.values()) }; } 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 }; } /** * Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert. * Ein Suchbegriff durchsucht Lern- und Referenzspalte (Teilstrings, ILIKE). */ async getLanguageDictionary(hashedUserId, languageId, query = {}) { const { q, page: pageParam, pageSize: pageSizeParam } = query; const user = await this._getUserByHashedId(hashedUserId); const access = await this._getLanguageAccess(user.id, languageId); const term = typeof q === 'string' ? q.trim() : ''; const like = term ? `%${term}%` : null; const { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam }); const baseReplacements = like ? { languageId: access.id, like } : { languageId: access.id }; const countRows = await sequelize.query( ` SELECT COUNT(*)::integer AS n 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 ${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''} `, { replacements: baseReplacements, type: sequelize.QueryTypes.SELECT, } ); const total = countRows[0]?.n ?? 0; const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize); const effectivePage = Math.min(Math.max(1, page), totalPages); const offset = (effectivePage - 1) * pageSize; let rows = []; if (total > 0) { rows = await sequelize.query( ` SELECT cl.id, c.id AS "chapterId", c.title AS "chapterTitle", l1.text AS "learning", l2.text AS "reference" 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 ${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''} ORDER BY c.title ASC, l1.text ASC, l2.text ASC LIMIT :limit OFFSET :offset `, { replacements: { ...baseReplacements, limit: pageSize, offset }, type: sequelize.QueryTypes.SELECT, } ); } return { languageId: access.id, results: rows, total, page: effectivePage, pageSize, totalPages, }; } /** * Wörterbuch: aus abgeschlossenen Kurslektionen extrahierte Paare, optional gefiltert (Teilstring in beiden Spalten). */ async getCourseDictionary(hashedUserId, courseId, query = {}) { const { q, page: pageParam, pageSize: pageSizeParam } = query; const pool = await this.getCompletedLessonVocabPool(hashedUserId, courseId); const term = typeof q === 'string' ? q.trim().toLowerCase() : ''; let vocabs = pool.vocabs || []; if (term) { vocabs = vocabs.filter((entry) => { const l = String(entry.learning || '').toLowerCase(); const r = String(entry.reference || '').toLowerCase(); return l.includes(term) || r.includes(term); }); } vocabs.sort((a, b) => { const refCmp = String(a.reference || '').localeCompare(String(b.reference || ''), undefined, { sensitivity: 'base' }); if (refCmp !== 0) return refCmp; return String(a.learning || '').localeCompare(String(b.learning || ''), undefined, { sensitivity: 'base' }); }); const { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam }); const total = vocabs.length; const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize); const effectivePage = Math.min(Math.max(1, page), totalPages); const offset = (effectivePage - 1) * pageSize; const paged = vocabs.slice(offset, offset + pageSize); return { courseId: pool.courseId, results: paged, total, page: effectivePage, pageSize, totalPages, }; } 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 // WICHTIG: directWhereProps muss NACH dem Setzen aller direkten Eigenschaften berechnet werden const directWhereProps = Object.keys(where).filter(key => { // Filtere Op.and und Op.or heraus (sind Symbol-Keys) return key !== Op.and && key !== Op.or && typeof key === 'string'; }); 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); } // Entferne leere Objekte aus andConditions const filteredConditions = andConditions.filter(cond => { return cond && typeof cond === 'object' && Object.keys(cond).length > 0; }); // Setze andConditions nur, wenn sie nicht leer sind if (filteredConditions.length > 0) { where[Op.and] = filteredConditions; } } // Wenn nur direkte Eigenschaften existieren (andConditions.length === 0), // bleiben sie in where (nichts zu tun, sie sind bereits dort) const courses = await VocabCourse.findAll({ where, order: [['createdAt', 'DESC']] }); const coursesData = courses.map(c => c.get({ plain: true })); await this._attachLanguageNamesToCourseRows(coursesData); 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; }); courseData.lessons = courseData.lessons.map((lesson) => ({ ...lesson, pedagogy: this._buildLessonPedagogy(lesson) })); await this._attachLanguageNamesToCourseRows([courseData]); return courseData; } /** Admin/Support: Kurs inkl. Lektionen ohne Sichtbarkeitsprüfung (nur serverseitig für Staff-Routen). */ async adminGetCourseWithLessonsForStaff(courseId) { const course = await VocabCourse.findByPk(Number(courseId), { include: [ { model: VocabCourseLesson, as: 'lessons', order: [['lessonNumber', 'ASC']] } ] }); if (!course) { const err = new Error('Course not found'); err.status = 404; throw err; } const courseData = course.get({ plain: true }); courseData.lessons = courseData.lessons || []; 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; }); courseData.lessons = courseData.lessons.map((lesson) => ({ ...lesson, pedagogy: this._buildLessonPedagogy(lesson) })); 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 }; } _seededShuffle(items, seed) { const arr = items.slice(); let t = (Number(seed) >>> 0) ^ 0x6a09e667; const rnd = () => { t ^= t << 13; t ^= t >>> 17; t ^= t << 5; return (t >>> 0) / 4294967296; }; for (let i = arr.length - 1; i > 0; i -= 1) { const j = Math.floor(rnd() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } _buildDeterministicChapterLexemeMcOptions(correct, distractorSource, seed) { const norm = (s) => this._normalizeTextAnswer(s); const nc = norm(correct); const filtered = distractorSource.filter((t) => norm(t) !== nc && String(t || '').trim()); const shuffled = this._seededShuffle(filtered, (seed ^ 0x9e3779b9) >>> 0); const picks = []; for (const p of shuffled) { if (picks.length >= 3) { break; } picks.push(p); } let pad = 1; while (picks.length < 3) { picks.push(`(${pad})`); pad += 1; } const ordered = this._seededShuffle([correct, ...picks], seed >>> 0); const correctAnswer = ordered.findIndex((o) => String(o) === String(correct)); return { options: ordered, correctAnswer: correctAnswer >= 0 ? correctAnswer : 0 }; } _lexemePairCoveredByMultipleChoice(exerciseList, learning, reference) { const nl = this._normalizeTextAnswer(learning); const nr = this._normalizeTextAnswer(reference); if (!nl || !nr) { return false; } for (const ex of exerciseList) { if (Number(ex.exerciseTypeId) !== 2) { continue; } const qd = typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData; if (!qd || qd.type !== 'multiple_choice') { continue; } const prompt = this._normalizeTextAnswer(qd.question || qd.text || ''); if (!prompt.includes(nl)) { continue; } const ad = typeof ex.answerData === 'string' ? JSON.parse(ex.answerData) : ex.answerData; const options = Array.isArray(qd.options) ? qd.options : []; let idx = ad?.correctAnswer; if (Array.isArray(idx)) { idx = idx[0]; } if (idx === undefined && ad?.correct !== undefined) { idx = Array.isArray(ad.correct) ? ad.correct[0] : ad.correct; } if (idx === undefined || options[Number(idx)] === undefined) { continue; } const correctOpt = this._normalizeTextAnswer(options[Number(idx)]); if (correctOpt === nr) { return true; } } return false; } _buildSyntheticLexemeMcExercisePlain(lessonId, row, exerciseNumber) { const learning = String(row.learning || '').trim(); const reference = String(row.reference || '').trim(); if (!learning || !reference) { return null; } const seed = (Number(row.id) * 100003 + Number(lessonId)) >>> 0; const { options, correctAnswer } = this._buildDeterministicChapterLexemeMcOptions( reference, row.allReferences || [], seed ); const questionData = { type: 'multiple_choice', question: `Was bedeutet „${learning}“?`, options, randomizeDistractors: false }; const answerData = { type: 'multiple_choice', correctAnswer }; return { id: `syn-${lessonId}-${row.id}-l2r`, lessonId, exerciseTypeId: 2, exerciseType: { id: 2, name: 'multiple_choice' }, exerciseNumber, title: `Kapitel-Vokabel: ${learning}`, instruction: 'Wähle die passende Übersetzung.', questionData, answerData, explanation: null, createdByUserId: 0 }; } async _fetchChapterLexemeRowsForMc(chapterId) { const id = Number.parseInt(chapterId, 10); if (!Number.isFinite(id)) { return []; } const rows = await sequelize.query( ` SELECT cl.id, l1.text AS learning, l2.text AS reference 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 cl.id ASC `, { replacements: { chapterId: id }, type: sequelize.QueryTypes.SELECT } ); return rows; } async _mergeSyntheticChapterLexemeMcExercises(plainLesson, grammarExercises) { const list = Array.isArray(grammarExercises) ? [...grammarExercises] : []; if (!plainLesson?.chapterId) { return list; } if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') { return list; } const rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId); if (!rows.length) { return list; } const allReferences = rows.map((r) => r.reference).filter(Boolean); let maxNum = list.reduce((m, ex) => Math.max(m, Number(ex.exerciseNumber) || 0), 0); const augmentedRows = rows.map((r) => ({ ...r, allReferences })); for (const row of augmentedRows) { if (this._lexemePairCoveredByMultipleChoice(list, row.learning, row.reference)) { continue; } maxNum += 1; const ex = this._buildSyntheticLexemeMcExercisePlain(plainLesson.id, row, maxNum); if (ex) { list.push(ex); } } return list; } async _checkSyntheticLexemeMcAnswer(user, lessonId, chapterLexemeId, userAnswer) { const lesson = await VocabCourseLesson.findByPk(lessonId, { include: [{ model: VocabCourse, as: 'course' }] }); if (!lesson) { const err = new Error('Exercise not found'); err.status = 404; throw err; } if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) { const err = new Error('Access denied'); err.status = 403; throw err; } 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; } if (!lesson.chapterId) { const err = new Error('Exercise not found'); err.status = 404; throw err; } const rows = await this._fetchChapterLexemeRowsForMc(lesson.chapterId); const row = rows.find((r) => Number(r.id) === Number(chapterLexemeId)); if (!row) { const err = new Error('Exercise not found'); err.status = 404; throw err; } const learning = String(row.learning || '').trim(); const reference = String(row.reference || '').trim(); if (!learning || !reference) { const err = new Error('Exercise not found'); err.status = 404; throw err; } const allReferences = rows.map((r) => r.reference).filter(Boolean); const seed = (Number(row.id) * 100003 + Number(lessonId)) >>> 0; const { options, correctAnswer } = this._buildDeterministicChapterLexemeMcOptions(reference, allReferences, seed); const questionData = { type: 'multiple_choice', question: `Was bedeutet „${learning}“?`, options, randomizeDistractors: false }; const answerData = { type: 'multiple_choice', correctAnswer }; const isCorrect = this._checkAnswer(answerData, questionData, userAnswer, 2); const correctIdx = Number(correctAnswer); const correctAnswerText = options[correctIdx]; const alternatives = options.filter((_, idx) => idx !== correctIdx); return { correct: isCorrect, correctAnswer: correctAnswerText || null, alternatives, explanation: null, progress: { attempts: 1, correctAttempts: isCorrect ? 1 : 0, lastAttemptAt: new Date(), completed: Boolean(isCorrect), completedAt: isCorrect ? new Date() : null } }; } async getLesson(hashedUserId, lessonId) { const user = await this._getUserByHashedId(hashedUserId); const lesson = await VocabCourseLesson.findByPk(lessonId, { include: [ { model: VocabCourse, as: 'course' }, { model: VocabGrammarExercise, as: 'grammarExercises', include: [ { model: VocabGrammarExerciseType, as: 'exerciseType' } ], required: false, separate: true, order: [['exerciseNumber', 'ASC']] } ] }); 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 progress = await VocabCourseProgress.findOne({ where: { userId: user.id, lessonId: lesson.id } }); const plainLesson = lesson.get({ plain: true }); // Lade Vokabeln aus vorherigen Lektionen (für Wiederholung UND für gemischten Vokabeltrainer) if (plainLesson.lessonNumber > 1) { plainLesson.previousLessonExercises = await this._getReviewVocabExercises(plainLesson.courseId, plainLesson.lessonNumber); } // Bei Wiederholungslektionen: Auch Lektions-Liste für Anzeige if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') { plainLesson.reviewLessons = await this._getReviewLessons(plainLesson.courseId, plainLesson.lessonNumber); plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || []; } plainLesson.grammarExercises = await this._mergeSyntheticChapterLexemeMcExercises( plainLesson, plainLesson.grammarExercises || [] ); plainLesson.didactics = this._buildLessonDidactics(plainLesson); plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson); plainLesson.progress = this._serializeLessonProgress(progress, plainLesson); return plainLesson; } async sendLessonAssistantMessage(hashedUserId, lessonId, payload = {}) { const user = await this._getUserByHashedId(hashedUserId); const lesson = await this.getLesson(hashedUserId, lessonId); const config = await this._getUserLlmConfig(user.id); if (!config.enabled) { const err = new Error('Der Sprachassistent ist in deinen Einstellungen derzeit deaktiviert.'); err.status = 400; throw err; } if (!config.configured) { const err = new Error('Der Sprachassistent ist noch nicht eingerichtet. Bitte hinterlege zuerst Modell und API-Zugang in den Einstellungen.'); err.status = 400; throw err; } const message = String(payload?.message || '').trim(); if (!message) { const err = new Error('Bitte gib eine Nachricht für den Sprachassistenten ein.'); err.status = 400; throw err; } const mode = ['explain', 'practice', 'correct'].includes(payload?.mode) ? payload.mode : 'practice'; const history = this._sanitizeAssistantHistory(payload?.history); const baseUrl = config.baseUrl || 'https://api.openai.com/v1'; const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`; const headers = { 'Content-Type': 'application/json' }; if (config.apiKey) { headers.Authorization = `Bearer ${config.apiKey}`; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30000); let response; try { response = await fetch(endpoint, { method: 'POST', headers, signal: controller.signal, body: JSON.stringify({ model: config.model, temperature: 0.7, messages: [ { role: 'system', content: this._buildLessonAssistantSystemPrompt(lesson, mode) }, ...history, { role: 'user', content: message } ] }) }); } catch (error) { const err = new Error( error?.name === 'AbortError' ? 'Der Sprachassistent hat zu lange für eine Antwort gebraucht.' : 'Der Sprachassistent konnte nicht erreicht werden.' ); err.status = 502; throw err; } finally { clearTimeout(timeout); } let responseData = null; try { responseData = await response.json(); } catch { responseData = null; } if (!response.ok) { const messageFromApi = responseData?.error?.message || responseData?.message || 'Der Sprachassistent hat die Anfrage abgelehnt.'; const err = new Error(messageFromApi); err.status = response.status || 502; throw err; } const reply = this._extractAssistantContent(responseData); if (!reply) { const err = new Error('Der Sprachassistent hat keine verwertbare Antwort geliefert.'); err.status = 502; throw err; } return { reply, model: responseData?.model || config.model, mode }; } /** * Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen */ async _getReviewLessons(courseId, currentLessonNumber) { const lessons = await VocabCourseLesson.findAll({ where: { courseId: courseId, lessonNumber: { [Op.lt]: currentLessonNumber // Nur Lektionen mit kleinerer Nummer }, lessonType: { [Op.notIn]: ['review', 'vocab_review'] // Keine anderen Wiederholungslektionen } }, order: [['lessonNumber', 'ASC']], attributes: ['id', 'lessonNumber', 'title'] }); return lessons.map(l => l.get({ plain: true })); } /** * Sammelt alle Grammatik-Übungen aus vorherigen Lektionen für Wiederholungslektionen */ async _getReviewVocabExercises(courseId, currentLessonNumber) { const previousLessons = await VocabCourseLesson.findAll({ where: { courseId: courseId, lessonNumber: { [Op.lt]: currentLessonNumber }, lessonType: { [Op.notIn]: ['review', 'vocab_review'] } }, attributes: ['id'] }); if (previousLessons.length === 0) { return []; } const lessonIds = previousLessons.map(l => l.id); const exercises = await VocabGrammarExercise.findAll({ where: { lessonId: { [Op.in]: lessonIds } }, include: [ { model: VocabGrammarExerciseType, as: 'exerciseType' }, { model: VocabCourseLesson, as: 'lesson', attributes: ['id', 'lessonNumber', 'title'] } ], order: [ [{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC'], ['exerciseNumber', 'ASC'] ] }); return exercises.map(e => e.get({ plain: true })); } async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, 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', didacticMode: this._normalizeOptionalString(didacticMode), phaseLabel: this._normalizeOptionalString(phaseLabel), blockNumber: this._normalizeOptionalInteger(blockNumber), difficultyWeight: this._normalizeOptionalInteger(difficultyWeight), newUnitTarget: this._normalizeOptionalInteger(newUnitTarget), reviewWeight: this._normalizeOptionalInteger(reviewWeight), isIntensiveReview: isIntensiveReview !== undefined ? Boolean(isIntensiveReview) : false, audioUrl: audioUrl || null, culturalNotes: culturalNotes || null, learningGoals: this._normalizeStringList(learningGoals), corePatterns: this._normalizeCorePatternList(corePatterns), grammarFocus: this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']), speakingPrompts: this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']), practicalTasks: this._normalizeStructuredList(practicalTasks, ['title', 'text']), 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, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, 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 (didacticMode !== undefined) updates.didacticMode = this._normalizeOptionalString(didacticMode); if (phaseLabel !== undefined) updates.phaseLabel = this._normalizeOptionalString(phaseLabel); if (blockNumber !== undefined) updates.blockNumber = this._normalizeOptionalInteger(blockNumber); if (difficultyWeight !== undefined) updates.difficultyWeight = this._normalizeOptionalInteger(difficultyWeight); if (newUnitTarget !== undefined) updates.newUnitTarget = this._normalizeOptionalInteger(newUnitTarget); if (reviewWeight !== undefined) updates.reviewWeight = this._normalizeOptionalInteger(reviewWeight); if (isIntensiveReview !== undefined) updates.isIntensiveReview = Boolean(isIntensiveReview); if (audioUrl !== undefined) updates.audioUrl = audioUrl; if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes; if (learningGoals !== undefined) updates.learningGoals = this._normalizeStringList(learningGoals); if (corePatterns !== undefined) updates.corePatterns = this._normalizeCorePatternList(corePatterns); if (grammarFocus !== undefined) updates.grammarFocus = this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']); if (speakingPrompts !== undefined) updates.speakingPrompts = this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']); if (practicalTasks !== undefined) updates.practicalTasks = this._normalizeStructuredList(practicalTasks, ['title', 'text']); 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 })); } /** * Kompakte Übersicht für das Start-Dashboard: eingeschriebene Kurse und „aktuelle“ Lektion * (gleiche Logik wie VocabCourseView.currentLesson: erste unvollständige, sonst letzte). */ async getDashboardLearningSummary(hashedUserId) { const user = await this._getUserByHashedId(hashedUserId); const enrollments = await VocabCourseEnrollment.findAll({ where: { userId: user.id }, include: [ { model: VocabCourse, as: 'course', required: true, attributes: ['id', 'title'] } ], order: [['enrolledAt', 'DESC']] }); const courseById = new Map(); for (const e of enrollments) { const c = e.course?.get({ plain: true }); if (!c?.id || courseById.has(c.id)) { continue; } courseById.set(c.id, { id: c.id, title: c.title || '' }); } const coursesMeta = [...courseById.values()]; if (coursesMeta.length === 0) { return { courses: [] }; } const courseIds = coursesMeta.map((c) => c.id); const lessons = await VocabCourseLesson.findAll({ where: { courseId: { [Op.in]: courseIds } }, attributes: ['id', 'courseId', 'lessonNumber', 'title'], order: [ ['courseId', 'ASC'], ['lessonNumber', 'ASC'] ] }); const progressRows = await VocabCourseProgress.findAll({ where: { userId: user.id, courseId: { [Op.in]: courseIds } }, attributes: ['lessonId', 'completed'] }); const completedByLessonId = new Map(); for (const row of progressRows) { const plain = row.get({ plain: true }); completedByLessonId.set(plain.lessonId, Boolean(plain.completed)); } const lessonsByCourse = new Map(); for (const row of lessons) { const plain = row.get({ plain: true }); const list = lessonsByCourse.get(plain.courseId) || []; list.push(plain); lessonsByCourse.set(plain.courseId, list); } const courses = []; for (const meta of coursesMeta) { const sorted = lessonsByCourse.get(meta.id) || []; if (sorted.length === 0) { courses.push({ courseId: meta.id, title: meta.title, currentLesson: null, allLessonsCompleted: false }); continue; } let current = null; for (const lesson of sorted) { if (!completedByLessonId.get(lesson.id)) { current = lesson; break; } } if (!current) { current = sorted[sorted.length - 1]; } const allLessonsCompleted = sorted.every((lesson) => completedByLessonId.get(lesson.id) === true); courses.push({ courseId: meta.id, title: meta.title, currentLesson: { id: current.id, lessonNumber: current.lessonNumber, title: current.title || '' }, allLessonsCompleted }); } return { courses }; } /** * Kurse, in die der Nutzer (per Hash) eingeschrieben ist — jede courseId nur einmal, * bei mehrfachen Einschreibungen zählt die jeweils neueste Zeile. */ async listEnrolledVocabCoursesForUser(targetHashedUserId) { const user = await this._getUserByHashedId(targetHashedUserId); const enrollments = await VocabCourseEnrollment.findAll({ where: { userId: user.id }, include: [{ model: VocabCourse, as: 'course', required: true }], order: [['enrolledAt', 'DESC']] }); const byCourseId = new Map(); for (const e of enrollments) { const row = e.course; if (!row) { continue; } const plain = row.get({ plain: true }); if (byCourseId.has(plain.id)) { continue; } byCourseId.set(plain.id, { ...plain, enrolledAt: e.enrolledAt }); } const coursesData = [...byCourseId.values()]; await this._attachLanguageNamesToCourseRows(coursesData); return coursesData; } 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((entry) => this._serializeLessonProgress(entry, entry.lesson)); } async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes, lessonState }) { 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; const sanitizedLessonState = lessonState === undefined ? undefined : this._sanitizeLessonState(lessonState); const didSubmitResult = completed !== undefined || score !== undefined; // 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, lessonState: sanitizedLessonState || {}, 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(); } } if (sanitizedLessonState !== undefined) { updates.lessonState = sanitizedLessonState; } const nextCompleted = updates.completed !== undefined ? Boolean(updates.completed) : Boolean(progress.completed); const mergedLessonState = { ...this._sanitizeLessonState(progress.lessonState), ...(updates.lessonState || {}) }; updates.lessonState = this._applyScheduledReviewState(mergedLessonState, { previousCompleted: Boolean(progress.completed), nextCompleted, shouldAdvanceReview: didSubmitResult && nextCompleted, lessonData, now: updates.completedAt || updates.lastAccessedAt || new Date() }); await progress.update(updates); } else if (isCompleted) { progress.completed = true; progress.completedAt = new Date(); progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState || {}, { previousCompleted: false, nextCompleted: true, shouldAdvanceReview: didSubmitResult, lessonData, now: progress.completedAt }); await progress.save(); } else if (sanitizedLessonState !== undefined) { progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState, { previousCompleted: false, nextCompleted: false, shouldAdvanceReview: false, lessonData, now: new Date() }); await progress.save(); } return this._serializeLessonProgress(progress, lessonData); } /** * Löscht nur den Fortschritt zu einer Lektion (Zeile vocab_course_progress + zugehörige grammar-exercise-progress). * Gesamtkurs / andere Lektionen bleiben unberührt. */ async _purgeLessonProgressForUser(userId, lessonId) { const numericLessonId = Number(lessonId); const exercises = await VocabGrammarExercise.findAll({ where: { lessonId: numericLessonId }, attributes: ['id'] }); const exerciseIds = exercises.map((e) => e.id); let deletedExerciseProgressRows = 0; if (exerciseIds.length > 0) { deletedExerciseProgressRows = await VocabGrammarExerciseProgress.destroy({ where: { userId, exerciseId: { [Op.in]: exerciseIds } } }); } const deletedLessonProgressRows = await VocabCourseProgress.destroy({ where: { userId, lessonId: numericLessonId } }); return { success: true, lessonId: numericLessonId, deletedLessonProgressRows, deletedExerciseProgressRows }; } /** Eingeloggter Nutzer setzt eigene Lektion zurück (nur bei Kurs-Einschreibung). */ async resetMyLessonProgress(hashedUserId, lessonId) { const user = await this._getUserByHashedId(hashedUserId); const lesson = await VocabCourseLesson.findByPk(Number(lessonId)); if (!lesson) { const err = new Error('Lesson not found'); err.status = 404; throw err; } 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; } return this._purgeLessonProgressForUser(user.id, lesson.id); } /** Admin: Zielnutzer per Hash, ohne Einschreibungszwang (idempotentes Löschen). */ async adminResetLessonProgressForUser(targetHashedUserId, lessonId) { const user = await this._getUserByHashedId(targetHashedUserId); const lesson = await VocabCourseLesson.findByPk(Number(lessonId)); if (!lesson) { const err = new Error('Lesson not found'); err.status = 404; throw err; } return this._purgeLessonProgressForUser(user.id, lesson.id); } /** * Admin: Alle Lektionen eines Kurses bis einschließlich lesson_number als abgeschlossen setzen * (nur Zeilen, die noch nicht completed sind). Nur bei eingeschriebenem Nutzer. */ async adminMarkLessonsCompleteThrough(targetHashedUserId, courseId, throughLessonNumber) { const user = await this._getUserByHashedId(targetHashedUserId); const cid = Number(courseId); const maxNum = Number(throughLessonNumber); if (!Number.isFinite(cid) || cid < 1) { const err = new Error('Invalid courseId'); err.status = 400; throw err; } if (!Number.isFinite(maxNum) || maxNum < 1) { const err = new Error('Invalid throughLessonNumber'); err.status = 400; throw err; } const enrollment = await VocabCourseEnrollment.findOne({ where: { userId: user.id, courseId: cid } }); if (!enrollment) { const err = new Error('Not enrolled in this course'); err.status = 403; throw err; } const lessons = await VocabCourseLesson.findAll({ where: { courseId: cid, lessonNumber: { [Op.lte]: maxNum } }, order: [['lessonNumber', 'ASC']] }); const now = new Date(); const details = []; for (const lesson of lessons) { const lessonData = lesson.get({ plain: true }); const targetScore = lessonData.targetScorePercent || 80; const [progress] = await VocabCourseProgress.findOrCreate({ where: { userId: user.id, lessonId: lesson.id }, defaults: { userId: user.id, courseId: cid, lessonId: lesson.id, completed: false, score: 0, lessonState: {} } }); if (progress.completed) { details.push({ lessonNumber: lesson.lessonNumber, lessonId: lesson.id, status: 'unchanged' }); continue; } const mergedState = this._applyScheduledReviewState( this._sanitizeLessonState(progress.lessonState), { previousCompleted: false, nextCompleted: true, shouldAdvanceReview: true, lessonData, now } ); await progress.update({ completed: true, completedAt: now, score: Math.max(Number(progress.score) || 0, targetScore), lastAccessedAt: now, lessonState: mergedState }); details.push({ lessonNumber: lesson.lessonNumber, lessonId: lesson.id, status: 'marked_complete' }); } return { courseId: cid, throughLessonNumber: maxNum, lessonsConsidered: lessons.length, details }; } // ========== 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']] }); const plainLesson = lesson.get({ plain: true }); const list = exercises.map((e) => e.get({ plain: true })); return await this._mergeSyntheticChapterLexemeMcExercises(plainLesson, list); } 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 exIdStr = String(exerciseId ?? ''); const synMatch = /^syn-(\d+)-(\d+)-l2r$/.exec(exIdStr); if (synMatch) { return this._checkSyntheticLexemeMcAnswer( user, Number(synMatch[1]), Number(synMatch[2]), userAnswer ); } 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; } const originalAnswerData = typeof exercise.answerData === 'string' ? JSON.parse(exercise.answerData) : exercise.answerData; const questionData = typeof exercise.questionData === 'string' ? JSON.parse(exercise.questionData) : exercise.questionData; const effectiveAnswerData = exercise.exerciseTypeId === 2 ? await this._expandMultipleChoiceAnswerData(exercise, originalAnswerData, questionData) : originalAnswerData; // Überprüfe Antwort const isCorrect = this._checkAnswer(effectiveAnswerData, questionData, 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(); } // Extrahiere richtige Antwort und Alternativen const answerData = effectiveAnswerData; let correctAnswer = null; let alternatives = []; // Für Multiple Choice: Extrahiere die richtige(n) Antwort(en) aus dem Index/den Indizes if (exercise.exerciseTypeId === 2 && answerData.correctAnswer !== undefined) { const options = questionData?.options || []; // Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten let correctIndices = []; if (Array.isArray(answerData.correctAnswer)) { correctIndices = answerData.correctAnswer.map(idx => Number(idx)); } else { correctIndices = [Number(answerData.correctAnswer)]; } // Extrahiere alle korrekten Antworten const correctAnswersList = correctIndices .map(idx => options[idx]) .filter(opt => opt !== undefined); if (correctAnswersList.length > 0) { // Wenn mehrere richtige Antworten: Zeige alle an, getrennt durch " / " correctAnswer = correctAnswersList.join(' / '); } // Alternativen sind alle anderen Optionen (nicht korrekte) alternatives = options.filter((opt, idx) => !correctIndices.includes(idx)); } // Für Gap Fill: Extrahiere aus answers Array else if (exercise.exerciseTypeId === 1 && answerData.answers) { correctAnswer = Array.isArray(answerData.answers) ? answerData.answers.join(', ') : answerData.answers; } // Für Reading Aloud: Extrahiere den erwarteten Text else if (questionData.type === 'reading_aloud') { correctAnswer = questionData.text || answerData.expectedText || ''; } // Für Speaking From Memory: Extrahiere erwarteten Text oder Schlüsselwörter else if (questionData.type === 'speaking_from_memory') { correctAnswer = questionData.expectedText || questionData.text || ''; alternatives = questionData.keywords || []; } else if (questionData.type === 'sentence_building' || questionData.type === 'dialog_completion' || questionData.type === 'situational_response' || questionData.type === 'pattern_drill') { const rawCorrect = answerData.correct ?? answerData.correctAnswer ?? answerData.answers ?? answerData.modelAnswer; if (Array.isArray(rawCorrect)) { correctAnswer = rawCorrect.join(' / '); } else { correctAnswer = rawCorrect || questionData.modelAnswer || ''; } alternatives = answerData.alternatives || questionData.keywords || []; } // Fallback: Versuche correct oder correctAnswer else { correctAnswer = Array.isArray(answerData.correct) ? answerData.correct[0] : (answerData.correct || answerData.correctAnswer); alternatives = answerData.alternatives || []; } return { correct: isCorrect, correctAnswer: correctAnswer, alternatives: alternatives, explanation: exercise.explanation, progress: progress.get({ plain: true }) }; } async _getExerciseTypeIdByName(typeName) { const type = await VocabGrammarExerciseType.findOne({ where: { name: typeName } }); return type ? type.id : null; } _extractMultipleChoiceIndices(answerData) { if (!answerData) return []; if (answerData.correctAnswer !== undefined) { return Array.isArray(answerData.correctAnswer) ? answerData.correctAnswer.map(idx => Number(idx)).filter(Number.isInteger) : [Number(answerData.correctAnswer)].filter(Number.isInteger); } if (answerData.correct !== undefined) { return Array.isArray(answerData.correct) ? answerData.correct.map(idx => Number(idx)).filter(Number.isInteger) : [Number(answerData.correct)].filter(Number.isInteger); } return []; } _getMultipleChoicePrompt(questionData) { return this._normalizeTextAnswer( questionData?.question || questionData?.text || questionData?.prompt || '' ); } async _expandMultipleChoiceAnswerData(exercise, answerData, questionData) { const options = Array.isArray(questionData?.options) ? questionData.options : []; const baseIndices = this._extractMultipleChoiceIndices(answerData); if (!options.length || !baseIndices.length || !exercise?.lessonId) { return answerData; } const prompt = this._getMultipleChoicePrompt(questionData); if (!prompt) { return answerData; } const optionIndexMap = new Map(); options.forEach((option, index) => { const normalizedOption = this._normalizeTextAnswer(option); if (!normalizedOption) return; const existing = optionIndexMap.get(normalizedOption) || []; existing.push(index); optionIndexMap.set(normalizedOption, existing); }); const lessonExercises = await VocabGrammarExercise.findAll({ where: { lessonId: exercise.lessonId, exerciseTypeId: 2 }, attributes: ['id', 'questionData', 'answerData'] }); const expandedIndices = new Set(baseIndices); lessonExercises.forEach((candidate) => { const candidateQuestionData = typeof candidate.questionData === 'string' ? JSON.parse(candidate.questionData) : candidate.questionData; const candidatePrompt = this._getMultipleChoicePrompt(candidateQuestionData); if (candidatePrompt !== prompt) { return; } const candidateAnswerData = typeof candidate.answerData === 'string' ? JSON.parse(candidate.answerData) : candidate.answerData; const candidateOptions = Array.isArray(candidateQuestionData?.options) ? candidateQuestionData.options : []; const candidateIndices = this._extractMultipleChoiceIndices(candidateAnswerData); candidateIndices.forEach((candidateIndex) => { const candidateOption = candidateOptions[candidateIndex]; const normalizedOption = this._normalizeTextAnswer(candidateOption); const matchingIndices = optionIndexMap.get(normalizedOption) || []; matchingIndices.forEach((matchingIndex) => expandedIndices.add(matchingIndex)); }); }); return { ...answerData, correctAnswer: Array.from(expandedIndices).sort((a, b) => a - b) }; } _checkAnswer(answerData, questionData, userAnswer, exerciseTypeId) { // Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden if (!answerData || userAnswer === undefined || userAnswer === null) return false; // Parse JSON strings const parsedAnswerData = typeof answerData === 'string' ? JSON.parse(answerData) : answerData; const parsedQuestionData = typeof questionData === 'string' ? JSON.parse(questionData) : questionData; // Für Multiple Choice: Prüfe ob userAnswer (Index) mit correctAnswer (Index oder Array von Indizes) übereinstimmt if (exerciseTypeId === 2) { // multiple_choice // Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten let correctIndices = []; if (parsedAnswerData.correctAnswer !== undefined) { // Kann ein einzelner Index oder ein Array von Indizes sein if (Array.isArray(parsedAnswerData.correctAnswer)) { correctIndices = parsedAnswerData.correctAnswer.map(idx => Number(idx)); } else { correctIndices = [Number(parsedAnswerData.correctAnswer)]; } } else if (parsedAnswerData.correct !== undefined) { // Fallback: Prüfe auch 'correct' Feld if (Array.isArray(parsedAnswerData.correct)) { correctIndices = parsedAnswerData.correct.map(idx => Number(idx)); } else { correctIndices = [Number(parsedAnswerData.correct)]; } } if (correctIndices.length === 0) return false; const options = Array.isArray(parsedQuestionData?.options) ? parsedQuestionData.options : []; const correctTexts = correctIndices .map((i) => options[i]) .filter((opt) => opt !== undefined && opt !== null); const norm = (s) => this._normalizeTextAnswer(s); // Nach zufälligen Distraktoren: Client sendet gewählten Optionstext statt Index if (typeof userAnswer === 'string') { const u = norm(userAnswer); if (!u) return false; return correctTexts.some((t) => norm(t) === u); } // Legacy: Index in die gespeicherten (nicht gemischten) Optionen const userIndex = Number(userAnswer); if (Number.isNaN(userIndex)) return false; return correctIndices.includes(userIndex); } // Für Lückentext: Normalisiere und vergleiche if (exerciseTypeId === 1) { // gap_fill const normalize = (str) => String(str || '').trim().toLowerCase(); const correctAnswers = parsedAnswerData.answers || parsedAnswerData.correct || []; const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers]; // userAnswer ist ein Array von Antworten if (Array.isArray(userAnswer)) { if (userAnswer.length !== correctAnswersArray.length) return false; return userAnswer.every((ans, idx) => { const correct = correctAnswersArray[idx]; return normalize(ans) === normalize(correct); }); } else { // Fallback: Einzelne Antwort const normalizedUserAnswer = normalize(userAnswer); return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer); } } // Für Reading Aloud: userAnswer ist der erkannte Text (String) // Vergleiche mit dem erwarteten Text aus questionData.text if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') { const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || ''; const normalizedExpected = this._normalizeTextAnswer(expectedText); const normalizedUser = this._normalizeTextAnswer(userAnswer); // Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz if (parsedQuestionData.type === 'reading_aloud') { // Exakter Vergleich (kann später mit Levenshtein erweitert werden) return normalizedUser === normalizedExpected; } // Für speaking_from_memory: Flexibler Vergleich (Schlüsselwörter) if (parsedQuestionData.type === 'speaking_from_memory') { const keywords = parsedQuestionData.keywords || []; if (keywords.length === 0) { // Fallback: Exakter Vergleich return normalizedUser === normalizedExpected; } // Prüfe ob alle Schlüsselwörter vorhanden sind return keywords.every(keyword => normalizedUser.includes(this._normalizeTextAnswer(keyword))); } } if (parsedQuestionData.type === 'sentence_building' || parsedQuestionData.type === 'dialog_completion' || parsedQuestionData.type === 'situational_response' || parsedQuestionData.type === 'pattern_drill') { const candidateAnswers = parsedAnswerData.correct ?? parsedAnswerData.correctAnswer ?? parsedAnswerData.answers ?? parsedAnswerData.modelAnswer ?? []; const normalizedUser = this._normalizeTextAnswer(userAnswer); const answers = Array.isArray(candidateAnswers) ? candidateAnswers : [candidateAnswers]; if (parsedQuestionData.type === 'situational_response') { const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || []; if (keywords.length > 0) { return keywords.every((keyword) => normalizedUser.includes(this._normalizeTextAnswer(keyword))); } } return answers .map((answer) => this._normalizeTextAnswer(answer)) .filter(Boolean) .some((answer) => answer === normalizedUser); } // Für andere Typen: einfacher String-Vergleich (kann später erweitert werden) const correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || []; const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers]; const normalizedUserAnswer = this._normalizeTextAnswer(userAnswer); return correctAnswersArray.some(correct => this._normalizeTextAnswer(correct) === normalizedUserAnswer); } async getGrammarExerciseProgress(hashedUserId, lessonId) { const user = await this._getUserByHashedId(hashedUserId); const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId); const numericExerciseIds = exercises .map((e) => e.id) .filter((id) => /^\d+$/.test(String(id))); const progress = numericExerciseIds.length ? await VocabGrammarExerciseProgress.findAll({ where: { userId: user.id, exerciseId: { [Op.in]: numericExerciseIds } } }) : []; 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 }; } /** * Explizite Zuordnung der Antwortsprache (sprachneutral). * questionData.answerLanguage: 'target' | 'native' * oder questionData.answerLanguageId: 1 = target (Lernsprache), 2 = native (Muttersprache) * Ohne diese Felder: 'unknown' (kein Eintrag in den Distraktor-Pools für diese Frage). * @param {object} questionData * @returns {'target'|'native'|'unknown'} */ _resolveMcAnswerSide(questionData) { if (!questionData || typeof questionData !== 'object') return 'unknown'; const raw = questionData.answerLanguage; if (typeof raw === 'string') { const s = raw.trim().toLowerCase(); if (s === 'target' || s === 'learning' || s === 'l2') return 'target'; if (s === 'native' || s === 'l1') return 'native'; } const id = questionData.answerLanguageId; if (id === 1 || id === '1') return 'target'; if (id === 2 || id === '2') return 'native'; return 'unknown'; } /** * Sammelt Vokabeln aus allen Multiple-Choice-Übungen von Lektionen **vor** der angegebenen Lektion * (gleicher Kurs), getrennt nach Ziel- vs. Muttersprache anhand von answerLanguage / answerLanguageId. */ async getVocabDistractorPool(hashedUserId, courseId, beforeLessonId) { if (!beforeLessonId) { const err = new Error('beforeLessonId is required'); err.status = 400; throw err; } 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 = 403; throw err; } const currentLesson = await VocabCourseLesson.findByPk(beforeLessonId); if (!currentLesson || currentLesson.courseId !== Number(courseId)) { const err = new Error('Lesson not found'); err.status = 404; throw err; } const priorLessons = await VocabCourseLesson.findAll({ where: { courseId: Number(courseId), lessonNumber: { [Op.lt]: currentLesson.lessonNumber }, }, attributes: ['id'], order: [['lessonNumber', 'ASC']], }); const lessonIds = priorLessons.map((l) => l.id); if (lessonIds.length === 0) { return { target: [], native: [] }; } const exercises = await VocabGrammarExercise.findAll({ where: { lessonId: { [Op.in]: lessonIds }, exerciseTypeId: 2, }, attributes: ['questionData'], }); const target = new Set(); const native = new Set(); for (const ex of exercises) { const qd = typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData; const opts = qd?.options; if (!Array.isArray(opts)) continue; const side = this._resolveMcAnswerSide(qd); if (side === 'target') { opts.forEach((o) => target.add(String(o).trim())); } else if (side === 'native') { opts.forEach((o) => native.add(String(o).trim())); } } return { target: [...target], native: [...native], }; } }