All checks were successful
Deploy to production / deploy (push) Successful in 2m57s
- Updated the `getGermanForBisayaLessonPedagogy` function to include lesson titles for improved didactic mode determination, specifically adding support for 'contrast_training'. - Modified multiple scripts to utilize the updated pedagogy function, ensuring consistent application of the new logic across various course phases. - Enhanced the VocabService to recognize and handle 'contrast_training' as a didactic mode, improving lesson management and user experience. - Updated UI components to reflect the new didactic mode, ensuring clarity in lesson presentation.
2966 lines
98 KiB
JavaScript
2966 lines
98 KiB
JavaScript
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 {
|
|
_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)));
|
|
}
|
|
|
|
_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 = {};
|
|
Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => {
|
|
if (!/^\d+$/.test(String(exerciseId))) {
|
|
return;
|
|
}
|
|
if (Array.isArray(answer)) {
|
|
sanitized[exerciseId] = this._sanitizeStringArray(answer, {
|
|
maxItems: 12,
|
|
maxLength: 200,
|
|
keepEmpty: true
|
|
});
|
|
return;
|
|
}
|
|
if (typeof answer === 'string') {
|
|
sanitized[exerciseId] = this._sanitizeShortString(answer, 200);
|
|
return;
|
|
}
|
|
if (typeof answer === 'number' && Number.isFinite(answer)) {
|
|
sanitized[exerciseId] = Math.trunc(answer);
|
|
}
|
|
});
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
_sanitizeExerciseResults(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const sanitized = {};
|
|
Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => {
|
|
if (!/^\d+$/.test(String(exerciseId)) || !result || typeof result !== 'object' || Array.isArray(result)) {
|
|
return;
|
|
}
|
|
|
|
sanitized[exerciseId] = {
|
|
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'
|
|
];
|
|
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 })
|
|
};
|
|
}
|
|
|
|
_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;
|
|
|
|
return {
|
|
...plainProgress,
|
|
lessonState: this._sanitizeLessonState(plainProgress.lessonState),
|
|
targetScore,
|
|
hasReachedTarget,
|
|
needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget)
|
|
};
|
|
}
|
|
|
|
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 _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 };
|
|
}
|
|
|
|
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']]
|
|
});
|
|
|
|
// Debug-Logging (kann später entfernt werden)
|
|
console.log(`[getCourses] Gefunden: ${courses.length} Kurse`, {
|
|
userId: user.id,
|
|
languageId,
|
|
nativeLanguageId,
|
|
search,
|
|
whereBefore: JSON.stringify(where, null, 2),
|
|
includePublic: includePublicBool,
|
|
includeOwn: includeOwnBool,
|
|
andConditionsLength: andConditions.length,
|
|
directWherePropsBefore: Object.keys(where).filter(key => key !== Op.and && key !== Op.or),
|
|
whereAfter: JSON.stringify(where, null, 2)
|
|
});
|
|
|
|
const coursesData = courses.map(c => c.get({ plain: true }));
|
|
|
|
// Lade Sprachnamen für alle Kurse
|
|
const languageIds = [...new Set(coursesData.map(c => c.languageId))];
|
|
if (languageIds.length > 0) {
|
|
const languages = await sequelize.query(
|
|
`SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`,
|
|
{
|
|
replacements: { languageIds },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Lade Muttersprachen-Namen für alle Kurse
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
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)
|
|
}));
|
|
|
|
return courseData;
|
|
}
|
|
|
|
async updateCourse(hashedUserId, courseId, { title, description, languageId, nativeLanguageId, difficultyLevel, isPublic }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can update the course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const updates = {};
|
|
if (title !== undefined) updates.title = title;
|
|
if (description !== undefined) updates.description = description;
|
|
if (languageId !== undefined) updates.languageId = Number(languageId);
|
|
if (nativeLanguageId !== undefined) updates.nativeLanguageId = nativeLanguageId ? Number(nativeLanguageId) : null;
|
|
if (difficultyLevel !== undefined) updates.difficultyLevel = Number(difficultyLevel);
|
|
if (isPublic !== undefined) {
|
|
updates.isPublic = Boolean(isPublic);
|
|
// Generiere Share-Code wenn Kurs öffentlich wird
|
|
if (isPublic && !course.shareCode) {
|
|
updates.shareCode = crypto.randomBytes(8).toString('hex');
|
|
} else if (!isPublic) {
|
|
updates.shareCode = null;
|
|
}
|
|
}
|
|
|
|
await course.update(updates);
|
|
return course.get({ plain: true });
|
|
}
|
|
|
|
async deleteCourse(hashedUserId, courseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can delete the course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
await course.destroy();
|
|
return { success: true };
|
|
}
|
|
|
|
async 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.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
|
|
}));
|
|
}
|
|
|
|
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);
|
|
|
|
// 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;
|
|
}
|
|
await progress.update(updates);
|
|
} else if (isCompleted) {
|
|
progress.completed = true;
|
|
progress.completedAt = new Date();
|
|
await progress.save();
|
|
}
|
|
|
|
return this._serializeLessonProgress(progress, lessonData);
|
|
}
|
|
|
|
// ========== GRAMMAR EXERCISE METHODS ==========
|
|
|
|
async getExerciseTypes() {
|
|
const types = await VocabGrammarExerciseType.findAll({
|
|
order: [['name', 'ASC']]
|
|
});
|
|
return types.map(t => t.get({ plain: true }));
|
|
}
|
|
|
|
async createGrammarExercise(hashedUserId, lessonId, { exerciseTypeId, exerciseNumber, title, instruction, questionData, answerData, explanation }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe, ob User Besitzer des Kurses ist
|
|
if (lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can add grammar exercises');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const exercise = await VocabGrammarExercise.create({
|
|
lessonId: lesson.id,
|
|
exerciseTypeId: Number(exerciseTypeId),
|
|
exerciseNumber: Number(exerciseNumber),
|
|
title,
|
|
instruction,
|
|
questionData,
|
|
answerData,
|
|
explanation,
|
|
createdByUserId: user.id
|
|
});
|
|
|
|
return exercise.get({ plain: true });
|
|
}
|
|
|
|
async getGrammarExercisesForLesson(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: { lessonId: lesson.id },
|
|
include: [{ model: VocabGrammarExerciseType, as: 'exerciseType' }],
|
|
order: [['exerciseNumber', 'ASC']]
|
|
});
|
|
|
|
return exercises.map(e => e.get({ plain: true }));
|
|
}
|
|
|
|
async getGrammarExercise(hashedUserId, exerciseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] },
|
|
{ model: VocabGrammarExerciseType, as: 'exerciseType' }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (exercise.lesson.course.ownerUserId !== user.id && !exercise.lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
return exercise.get({ plain: true });
|
|
}
|
|
|
|
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Einschreibung
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: exercise.lesson.courseId }
|
|
});
|
|
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
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 exerciseIds = exercises.map(e => e.id);
|
|
const progress = await VocabGrammarExerciseProgress.findAll({
|
|
where: {
|
|
userId: user.id,
|
|
exerciseId: { [Op.in]: exerciseIds }
|
|
}
|
|
});
|
|
|
|
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
|
|
|
|
return exercises.map(exercise => ({
|
|
...exercise,
|
|
progress: progressMap.get(exercise.id) || null
|
|
}));
|
|
}
|
|
|
|
async updateGrammarExercise(hashedUserId, exerciseId, { title, instruction, questionData, answerData, explanation, exerciseNumber }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (exercise.lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can update exercises');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const updates = {};
|
|
if (title !== undefined) updates.title = title;
|
|
if (instruction !== undefined) updates.instruction = instruction;
|
|
if (questionData !== undefined) updates.questionData = questionData;
|
|
if (answerData !== undefined) updates.answerData = answerData;
|
|
if (explanation !== undefined) updates.explanation = explanation;
|
|
if (exerciseNumber !== undefined) updates.exerciseNumber = Number(exerciseNumber);
|
|
|
|
await exercise.update(updates);
|
|
return exercise.get({ plain: true });
|
|
}
|
|
|
|
async deleteGrammarExercise(hashedUserId, exerciseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (exercise.lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can delete exercises');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
await exercise.destroy();
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Ordnet eine Multiple-Choice-Frage der Zielsprache (zu lernen) oder Muttersprache (Erklärung) zu,
|
|
* damit Distraktoren aus dem passenden Wortpool gewählt werden können.
|
|
* @returns {'target'|'native'|'unknown'}
|
|
*/
|
|
_classifyMcQuestionSide(question) {
|
|
const q = String(question || '');
|
|
if (/Wie sagt man\s/i.test(q) || /Übersetze/i.test(q)) return 'target';
|
|
if (/Was bedeutet/i.test(q)) 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 der Frageformulierung.
|
|
*/
|
|
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 question = qd?.question || '';
|
|
const opts = qd?.options;
|
|
if (!Array.isArray(opts)) continue;
|
|
const side = this._classifyMcQuestionSide(question);
|
|
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],
|
|
};
|
|
}
|
|
}
|