Files
yourpart3/backend/services/vocabService.js
Torsten Schulz (local) 09a10ff830
All checks were successful
Deploy to production / deploy (push) Successful in 2m54s
feat(bisaya-course, vocabService): expand numerical didactics and enhance lesson progress serialization
- Added new glosses for the numbers sixty, seventy, and eighty in the Bisaya didactics, enriching the numerical curriculum.
- Updated the _serializeLessonProgress method to include an options parameter for suppressing lesson review due notifications based on SRS item status.
- Introduced a new method to check for due SRS items, improving the handling of lesson progress and review scheduling.
2026-04-17 09:30:36 +02:00

4180 lines
138 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 VocabSrsItem from '../models/community/vocab_srs_item.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, BISAYA_DIDACTICS_FRAGMENTS } from '../scripts/bisaya-course-phase1.js';
export default class VocabService {
_normalizeSrsText(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
_buildSrsItemKey({ courseId, lessonId = null, learning, reference, direction = 'BOTH' }) {
const raw = [
Number(courseId) || 0,
lessonId == null ? 'course' : Number(lessonId) || 0,
String(direction || 'BOTH').toUpperCase(),
this._normalizeSrsText(learning),
this._normalizeSrsText(reference)
].join('|');
return crypto.createHash('sha1').update(raw).digest('hex');
}
_decorateSrsVocabs(vocabs = [], { courseId, lessonId = null } = {}) {
return (Array.isArray(vocabs) ? vocabs : [])
.map((entry) => {
const learning = String(entry?.learning || '').trim();
const reference = String(entry?.reference || '').trim();
if (!learning || !reference || this._normalizeSrsText(learning) === this._normalizeSrsText(reference)) {
return null;
}
const direction = String(entry?.direction || 'BOTH').toUpperCase();
const itemKey = this._buildSrsItemKey({ courseId, lessonId, learning, reference, direction });
return {
...entry,
id: entry?.id || itemKey,
itemKey,
courseId: Number(courseId) || null,
lessonId: lessonId == null ? null : Number(lessonId),
learning,
reference,
direction
};
})
.filter(Boolean);
}
_calculateSrsSchedule(item, { correct, rating = null } = {}) {
const now = new Date();
const previousStage = Math.max(0, Number(item?.stage) || 0);
const previousInterval = Math.max(0, Number(item?.intervalDays) || 0);
const normalizedRating = String(rating || '').toLowerCase();
const isCorrect = Boolean(correct) && normalizedRating !== 'again';
if (!isCorrect) {
return {
stage: Math.max(0, previousStage - 1),
intervalDays: 0,
nextDueAt: new Date(now.getTime() + 10 * 60 * 1000),
lapseDelta: 1
};
}
const intervals = [0, 1, 3, 7, 14, 30, 60, 120, 240];
let nextStage = Math.min(intervals.length - 1, previousStage + 1);
if (normalizedRating === 'hard') {
nextStage = Math.max(1, previousStage);
}
if (normalizedRating === 'easy') {
nextStage = Math.min(intervals.length - 1, previousStage + 2);
}
let intervalDays = intervals[nextStage] ?? Math.max(1, previousInterval * 2);
if (normalizedRating === 'hard') {
intervalDays = Math.max(1, Math.ceil(Math.max(previousInterval, 1) * 1.2));
}
return {
stage: nextStage,
intervalDays,
nextDueAt: new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000),
lapseDelta: 0
};
}
async _ensureSrsItems(userId, { courseId, lessonId = null, vocabs = [] } = {}) {
const decorated = this._decorateSrsVocabs(vocabs, { courseId, lessonId });
if (!decorated.length) {
return [];
}
const existing = await VocabSrsItem.findAll({
where: {
userId,
itemKey: {
[Op.in]: decorated.map((entry) => entry.itemKey)
}
}
});
const existingByKey = new Map(existing.map((entry) => [entry.itemKey, entry]));
const now = new Date();
const createdItems = [];
for (const entry of decorated) {
if (existingByKey.has(entry.itemKey)) {
continue;
}
const created = await VocabSrsItem.create({
userId,
courseId: Number(courseId),
lessonId: entry.lessonId,
itemKey: entry.itemKey,
learning: entry.learning,
reference: entry.reference,
direction: entry.direction,
nextDueAt: now
});
createdItems.push(created);
existingByKey.set(entry.itemKey, created);
}
return decorated.map((entry) => {
const item = existingByKey.get(entry.itemKey);
return {
...entry,
srs: item ? {
stage: item.stage,
intervalDays: item.intervalDays,
lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt),
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
correctCount: item.correctCount,
wrongCount: item.wrongCount,
lapseCount: item.lapseCount,
isNew: createdItems.some((created) => created.itemKey === entry.itemKey)
} : null
};
});
}
_normalizeIsoDate(value) {
if (!value) {
return '';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toISOString();
}
_clampInteger(value, { min = 0, max = 100000, fallback = 0 } = {}) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.trunc(numeric)));
}
/**
* Wörterbuch-API: page ab 1, pageSize max. 100, Standard 25.
*/
_parseDictionaryPaging(query = {}) {
const page = Math.max(1, this._clampInteger(query?.page, { min: 1, max: 1_000_000, fallback: 1 }));
const pageSize = this._clampInteger(query?.pageSize, { min: 1, max: 100, fallback: 25 });
return { page, pageSize };
}
_sanitizeShortString(value, maxLength = 400) {
const text = String(value ?? '').trim();
if (!text) {
return '';
}
return text.slice(0, maxLength);
}
_sanitizeStringArray(value, { maxItems = 12, maxLength = 400, keepEmpty = false } = {}) {
if (!Array.isArray(value)) {
return [];
}
return value
.slice(0, maxItems)
.map((entry) => this._sanitizeShortString(entry, maxLength))
.filter((entry) => keepEmpty || Boolean(entry));
}
_sanitizeExerciseAnswers(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const sanitized = {};
const synMcId = /^syn-\d+-\d+-l2r$/;
Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => {
const idStr = String(exerciseId);
if (!/^\d+$/.test(idStr) && !synMcId.test(idStr)) {
return;
}
if (Array.isArray(answer)) {
sanitized[idStr] = this._sanitizeStringArray(answer, {
maxItems: 12,
maxLength: 200,
keepEmpty: true
});
return;
}
if (typeof answer === 'string') {
sanitized[idStr] = this._sanitizeShortString(answer, 200);
return;
}
if (typeof answer === 'number' && Number.isFinite(answer)) {
sanitized[idStr] = Math.trunc(answer);
}
});
return sanitized;
}
_sanitizeExerciseResults(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const synMcId = /^syn-\d+-\d+-l2r$/;
const sanitized = {};
Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => {
const idStr = String(exerciseId);
if ((!/^\d+$/.test(idStr) && !synMcId.test(idStr)) || !result || typeof result !== 'object' || Array.isArray(result)) {
return;
}
sanitized[idStr] = {
correct: Boolean(result.correct),
correctAnswer: this._sanitizeShortString(result.correctAnswer, 400),
alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }),
explanation: this._sanitizeShortString(result.explanation, 1200)
};
});
return sanitized;
}
_sanitizeVocabTrainerStats(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const sanitized = {};
Object.entries(value).slice(0, 400).forEach(([key, stats]) => {
const safeKey = this._sanitizeShortString(key, 200);
if (!safeKey || !stats || typeof stats !== 'object' || Array.isArray(stats)) {
return;
}
sanitized[safeKey] = {
attempts: this._clampInteger(stats.attempts, { max: 5000 }),
correct: this._clampInteger(stats.correct, { max: 5000 }),
wrong: this._clampInteger(stats.wrong, { max: 5000 })
};
});
return sanitized;
}
_sanitizeRepeatQueue(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.slice(0, 100)
.map((entry) => ({
key: this._sanitizeShortString(entry?.key, 200),
dueAfter: this._clampInteger(entry?.dueAfter, { min: 0, max: 50 }),
stageIndex: this._clampInteger(entry?.stageIndex, { min: 0, max: 10 })
}))
.filter((entry) => entry.key);
}
_sanitizeLessonState(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const knownKeys = [
'version',
'updatedAt',
'activeTab',
'exercisePreparationCompleted',
'lessonPrepStage',
'lessonPrepIndex',
'vocabTrainerActive',
'vocabTrainerMode',
'vocabTrainerAutoSwitchedToTyping',
'vocabTrainerCorrect',
'vocabTrainerWrong',
'vocabTrainerTotalAttempts',
'vocabTrainerCurrentAttempts',
'vocabTrainerReviewAttempts',
'vocabTrainerStats',
'vocabTrainerRepeatQueue',
'exerciseAnswers',
'exerciseResults',
'exerciseRetryPending',
'exerciseRetryPendingSinceAttempts',
'reviewStage',
'reviewNextDueAt',
'reviewLastReviewedAt'
];
const hasKnownState = knownKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key));
if (!hasKnownState) {
return {};
}
const activeTab = value.activeTab === 'exercises' ? 'exercises' : 'learn';
const vocabTrainerMode = value.vocabTrainerMode === 'typing' ? 'typing' : 'multiple_choice';
return {
version: this._clampInteger(value.version, { min: 1, max: 1000, fallback: 1 }),
updatedAt: this._sanitizeShortString(value.updatedAt || new Date().toISOString(), 64),
activeTab,
exercisePreparationCompleted: Boolean(value.exercisePreparationCompleted),
lessonPrepStage: this._clampInteger(value.lessonPrepStage, { min: 0, max: 2 }),
lessonPrepIndex: this._clampInteger(value.lessonPrepIndex, { min: 0, max: 500 }),
vocabTrainerActive: Boolean(value.vocabTrainerActive),
vocabTrainerMode,
vocabTrainerAutoSwitchedToTyping: Boolean(value.vocabTrainerAutoSwitchedToTyping),
vocabTrainerCorrect: this._clampInteger(value.vocabTrainerCorrect, { max: 5000 }),
vocabTrainerWrong: this._clampInteger(value.vocabTrainerWrong, { max: 5000 }),
vocabTrainerTotalAttempts: this._clampInteger(value.vocabTrainerTotalAttempts, { max: 10000 }),
vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }),
vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }),
vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats),
vocabTrainerRepeatQueue: this._sanitizeRepeatQueue(value.vocabTrainerRepeatQueue),
exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers),
exerciseResults: this._sanitizeExerciseResults(value.exerciseResults),
exerciseRetryPending: Boolean(value.exerciseRetryPending),
exerciseRetryPendingSinceAttempts: this._clampInteger(value.exerciseRetryPendingSinceAttempts, { max: 10000 }),
reviewStage: this._clampInteger(value.reviewStage, { min: 0, max: 3 }),
reviewNextDueAt: this._normalizeIsoDate(value.reviewNextDueAt),
reviewLastReviewedAt: this._normalizeIsoDate(value.reviewLastReviewedAt)
};
}
_supportsScheduledReview(lessonData = null) {
const lessonType = String(lessonData?.lessonType || '').toLowerCase();
const didacticMode = String(lessonData?.didacticMode || '').toLowerCase();
if (lessonType === 'culture' || lessonType === 'review' || lessonType === 'vocab_review') {
return false;
}
if (didacticMode === 'intensive_review' || didacticMode === 'checkpoint') {
return false;
}
return true;
}
_removeManagedReviewState(lessonState = {}) {
const nextState = { ...(lessonState || {}) };
delete nextState.reviewStage;
delete nextState.reviewNextDueAt;
delete nextState.reviewLastReviewedAt;
return nextState;
}
_applyScheduledReviewState(lessonState = {}, {
previousCompleted = false,
nextCompleted = false,
shouldAdvanceReview = false,
lessonData = null,
now = new Date()
} = {}) {
const baseState = this._sanitizeLessonState(lessonState);
if (!this._supportsScheduledReview(lessonData) || !nextCompleted) {
return this._removeManagedReviewState(baseState);
}
const reviewIntervalsDays = [1, 3, 7];
const currentStage = this._clampInteger(baseState.reviewStage, { min: 0, max: reviewIntervalsDays.length });
const dueAtIso = this._normalizeIsoDate(baseState.reviewNextDueAt);
const dueAt = dueAtIso ? new Date(dueAtIso) : null;
const reviewLastReviewedAt = this._normalizeIsoDate(baseState.reviewLastReviewedAt);
const nowIso = this._normalizeIsoDate(now);
const nextState = {
...baseState,
reviewStage: currentStage,
reviewNextDueAt: dueAtIso,
reviewLastReviewedAt
};
if (!previousCompleted && shouldAdvanceReview) {
nextState.reviewStage = 0;
nextState.reviewLastReviewedAt = nowIso;
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[0] * 24 * 60 * 60 * 1000));
return nextState;
}
if (!dueAtIso && currentStage < reviewIntervalsDays.length) {
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[currentStage] * 24 * 60 * 60 * 1000));
}
if (!shouldAdvanceReview || !dueAt || Number.isNaN(dueAt.getTime()) || now.getTime() < dueAt.getTime()) {
return nextState;
}
const nextStage = Math.min(reviewIntervalsDays.length, currentStage + 1);
nextState.reviewStage = nextStage;
nextState.reviewLastReviewedAt = nowIso;
nextState.reviewNextDueAt = nextStage >= reviewIntervalsDays.length
? ''
: this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[nextStage] * 24 * 60 * 60 * 1000));
return nextState;
}
_serializeLessonProgress(progress, lessonData = null, options = {}) {
if (!progress) {
return null;
}
const plainProgress = progress.get ? progress.get({ plain: true }) : { ...progress };
const targetScore = lessonData?.targetScorePercent || plainProgress.lesson?.targetScorePercent || 80;
const hasReachedTarget = (plainProgress.score || 0) >= targetScore;
const lessonState = this._sanitizeLessonState(plainProgress.lessonState);
const reviewStage = this._clampInteger(lessonState.reviewStage, { min: 0, max: 3 });
const reviewNextDueAt = this._normalizeIsoDate(lessonState.reviewNextDueAt);
const suppressLessonReviewDue = Boolean(options.suppressLessonReviewDue);
const reviewDue = !suppressLessonReviewDue && Boolean(reviewNextDueAt && reviewStage < 3 && new Date(reviewNextDueAt).getTime() <= Date.now());
return {
...plainProgress,
lessonId: Number(plainProgress.lessonId),
lessonNumber: lessonData?.lessonNumber ?? plainProgress.lesson?.lessonNumber ?? null,
lessonState,
targetScore,
hasReachedTarget,
needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget),
reviewStage,
reviewNextDueAt,
reviewDue,
reviewCompleted: reviewStage >= 3,
reviewSuppressedBySrs: suppressLessonReviewDue
};
}
async _courseHasDueSrsItems(userId, courseId) {
const due = await VocabSrsItem.count({
where: {
userId,
courseId: Number(courseId),
nextDueAt: {
[Op.lte]: new Date()
}
}
});
return due > 0;
}
async _getUserByHashedId(hashedUserId) {
const user = await User.findOne({ where: { hashedId: hashedUserId } });
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
return user;
}
async _attachLanguageNamesToCourseRows(coursesData) {
if (!coursesData.length) {
return;
}
const languageIds = [...new Set(coursesData.map((c) => c.languageId))];
if (languageIds.length > 0) {
const languages = await sequelize.query(
`SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`,
{
replacements: { languageIds },
type: sequelize.QueryTypes.SELECT
}
);
if (Array.isArray(languages)) {
const languageMap = new Map(languages.map((l) => [l.id, l.name]));
coursesData.forEach((c) => {
c.languageName = languageMap.get(c.languageId) || null;
});
}
}
const nativeLanguageIds = [...new Set(coursesData.map((c) => c.nativeLanguageId).filter((id) => id !== null))];
if (nativeLanguageIds.length > 0) {
const nativeLanguages = await sequelize.query(
`SELECT id, name FROM community.vocab_language WHERE id IN (:nativeLanguageIds)`,
{
replacements: { nativeLanguageIds },
type: sequelize.QueryTypes.SELECT
}
);
if (Array.isArray(nativeLanguages)) {
const nativeLanguageMap = new Map(nativeLanguages.map((l) => [l.id, l.name]));
coursesData.forEach((c) => {
c.nativeLanguageName = c.nativeLanguageId ? nativeLanguageMap.get(c.nativeLanguageId) || null : null;
});
}
}
}
async _getUserLlmConfig(userId) {
const [settingsType, apiKeyType] = await Promise.all([
UserParamType.findOne({ where: { description: 'llm_settings' } }),
UserParamType.findOne({ where: { description: 'llm_api_key' } })
]);
if (!settingsType || !apiKeyType) {
return {
enabled: false,
baseUrl: '',
model: 'gpt-4o-mini',
hasKey: false,
apiKey: null,
configured: false
};
}
const [settingsRow, keyRow] = await Promise.all([
UserParam.findOne({ where: { userId, paramTypeId: settingsType.id } }),
UserParam.findOne({ where: { userId, paramTypeId: apiKeyType.id } })
]);
let parsed = {};
if (settingsRow?.value) {
try {
parsed = JSON.parse(settingsRow.value);
} catch {
parsed = {};
}
}
const decryptedKey = keyRow?.value ? String(keyRow.value).trim() : null;
const hasKey = Boolean(decryptedKey && String(decryptedKey).trim());
const enabled = parsed.enabled !== false;
const baseUrl = String(parsed.baseUrl || '').trim();
return {
enabled,
baseUrl,
model: String(parsed.model || 'gpt-4o-mini').trim() || 'gpt-4o-mini',
hasKey,
apiKey: hasKey ? decryptedKey : null,
configured: enabled && (hasKey || Boolean(baseUrl))
};
}
_sanitizeAssistantHistory(history) {
if (!Array.isArray(history)) {
return [];
}
return history
.slice(-8)
.map((entry) => ({
role: entry?.role === 'assistant' ? 'assistant' : 'user',
content: String(entry?.content || '').trim()
}))
.filter((entry) => entry.content);
}
_buildLessonAssistantSystemPrompt(lesson, mode = 'practice') {
const didactics = lesson?.didactics || {};
const learningGoals = Array.isArray(didactics.learningGoals) ? didactics.learningGoals : [];
const corePatterns = Array.isArray(didactics.corePatterns) ? didactics.corePatterns : [];
const speakingPrompts = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts : [];
const practicalTasks = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks : [];
const modeDirectives = {
explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.',
practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.',
correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.'
};
return [
'Du bist ein didaktischer Sprachassistent innerhalb eines Sprachkurses.',
'Antworte auf Deutsch, aber verwende die Zielsprache der Lektion aktiv in Beispielen und Mini-Dialogen.',
modeDirectives[mode] || modeDirectives.practice,
'Halte Antworten kompakt, praxisnah und auf diese Lektion fokussiert.',
`Kurs: ${lesson?.course?.title || 'Unbekannter Kurs'}`,
`Lektion: ${lesson?.title || 'Unbekannte Lektion'}`,
lesson?.description ? `Beschreibung: ${lesson.description}` : '',
learningGoals.length ? `Lernziele: ${learningGoals.join(' | ')}` : '',
corePatterns.length
? `Kernmuster: ${corePatterns.map((p) => {
const n = this._normalizeCorePatternEntry(p);
if (!n) return '';
return n.gloss ? `${n.target} (${n.gloss})` : n.target;
}).filter(Boolean).join(' | ')}`
: '',
speakingPrompts.length
? `Sprechaufträge: ${speakingPrompts.map((item) => item.prompt || item.title || '').filter(Boolean).join(' | ')}`
: '',
practicalTasks.length
? `Praxisaufgaben: ${practicalTasks.map((item) => item.text || item.title || '').filter(Boolean).join(' | ')}`
: '',
'Wenn der Nutzer eine Formulierung versucht, korrigiere sie präzise und gib eine verbesserte Version.'
].filter(Boolean).join('\n');
}
_extractAssistantContent(responseData) {
const rawContent = responseData?.choices?.[0]?.message?.content;
if (typeof rawContent === 'string') {
return rawContent.trim();
}
if (Array.isArray(rawContent)) {
return rawContent
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text || '';
return '';
})
.join('\n')
.trim();
}
return '';
}
_normalizeLexeme(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
_normalizeTextAnswer(text) {
const normalized = String(text || '')
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized.replace(/\s+/g, '');
}
_expandComparableAnswerVariants(text) {
const raw = String(text || '').trim();
if (!raw) return [];
const variants = new Set([raw]);
// Beispiel: "heute (heute am Tag)" -> "heute" und "heute am Tag"
const withoutParentheses = raw.replace(/\s*\([^)]*\)\s*/g, ' ').replace(/\s+/g, ' ').trim();
if (withoutParentheses) variants.add(withoutParentheses);
const parenMatches = raw.match(/\(([^)]+)\)/g) || [];
parenMatches.forEach((chunk) => {
const content = chunk.replace(/[()]/g, '').trim();
if (content) variants.add(content);
});
return Array.from(variants);
}
_isEquivalentAnswer(userAnswer, canonicalAnswer) {
const normalizedUser = this._normalizeTextAnswer(userAnswer);
if (!normalizedUser) return false;
const canonicalVariants = this._expandComparableAnswerVariants(canonicalAnswer)
.map((entry) => this._normalizeTextAnswer(entry))
.filter(Boolean);
return canonicalVariants.includes(normalizedUser);
}
_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 : [];
corePatterns.forEach((entry) => {
const pattern = this._normalizeCorePatternEntry(entry);
const reference = String(pattern?.target || '').trim();
const learning = String(pattern?.gloss || '').trim();
if (!learning || !reference || learning === reference) return;
vocabMap.set(`${learning}-${reference}`, { learning, reference });
});
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 || [];
let resolvedCorePatterns = this._mergeCorePatternGlosses(
this._enrichCorePatternsWithGloss(
this._normalizeCorePatternList(plainLesson.corePatterns),
extractedTrainerVocabs
),
phase1FallbackCorePatterns
);
if (
!resolvedCorePatterns.length
&& BISAYA_DIDACTICS_FRAGMENTS[plainLesson.title]?.corePatterns?.length
) {
const frag = BISAYA_DIDACTICS_FRAGMENTS[plainLesson.title];
resolvedCorePatterns = this._mergeCorePatternGlosses(
this._enrichCorePatternsWithGloss(
this._normalizeCorePatternList(frag.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: resolvedCorePatterns.length > 0
? resolvedCorePatterns
: 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 fallbackVocabs = this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }));
const mergedVocabs = new Map();
[...fallbackVocabs, ...extractedFromExercises].forEach((entry) => {
if (!entry?.learning || !entry?.reference) return;
mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry);
});
const vocabs = await this._ensureSrsItems(user.id, {
courseId: lesson.courseId,
lessonId: lesson.id,
vocabs: Array.from(mergedVocabs.values())
});
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);
});
const vocabs = await this._ensureSrsItems(user.id, {
courseId: course.id,
lessonId: null,
vocabs: Array.from(mergedVocabs.values())
});
return {
courseId: course.id,
vocabs
};
}
async getCourseSrsDue(hashedUserId, courseId, query = {}) {
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;
}
const limit = this._clampInteger(query?.limit, { min: 1, max: 100, fallback: 30 });
const now = new Date();
const rows = await VocabSrsItem.findAll({
where: {
userId: user.id,
courseId: Number(course.id),
nextDueAt: {
[Op.lte]: now
}
},
order: [
['nextDueAt', 'ASC'],
['wrongCount', 'DESC'],
['stage', 'ASC']
],
limit
});
return {
courseId: course.id,
dueAt: now.toISOString(),
count: rows.length,
items: rows.map((item) => ({
itemKey: item.itemKey,
courseId: item.courseId,
lessonId: item.lessonId,
learning: item.learning,
reference: item.reference,
direction: item.direction,
stage: item.stage,
intervalDays: item.intervalDays,
lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt),
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
correctCount: item.correctCount,
wrongCount: item.wrongCount,
lapseCount: item.lapseCount
}))
};
}
async reviewSrsItem(hashedUserId, payload = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const courseId = this._clampInteger(payload?.courseId, { min: 1, max: 1_000_000, fallback: 0 });
if (!courseId) {
const err = new Error('Missing course id');
err.status = 400;
throw err;
}
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;
}
const learning = this._sanitizeShortString(payload?.learning, 1200);
const reference = this._sanitizeShortString(payload?.reference, 1200);
if (!learning || !reference) {
const err = new Error('Missing SRS item text');
err.status = 400;
throw err;
}
const lessonId = payload?.lessonId == null
? null
: this._clampInteger(payload.lessonId, { min: 1, max: 1_000_000, fallback: 0 }) || null;
const direction = String(payload?.direction || 'BOTH').toUpperCase().slice(0, 8);
const itemKey = this._sanitizeShortString(payload?.itemKey, 80)
|| this._buildSrsItemKey({ courseId, lessonId, learning, reference, direction });
const [item] = await VocabSrsItem.findOrCreate({
where: {
userId: user.id,
itemKey
},
defaults: {
userId: user.id,
courseId,
lessonId,
itemKey,
learning,
reference,
direction,
nextDueAt: new Date()
}
});
if (
item.learning !== learning ||
item.reference !== reference ||
item.direction !== direction ||
item.lessonId !== lessonId
) {
item.learning = learning;
item.reference = reference;
item.direction = direction;
item.lessonId = lessonId;
}
const correct = Boolean(payload?.correct);
const schedule = this._calculateSrsSchedule(item, {
correct,
rating: payload?.rating
});
item.stage = schedule.stage;
item.intervalDays = schedule.intervalDays;
item.lastReviewedAt = new Date();
item.nextDueAt = schedule.nextDueAt;
if (correct) {
item.correctCount += 1;
} else {
item.wrongCount += 1;
item.lapseCount += schedule.lapseDelta;
}
await item.save();
return {
itemKey: item.itemKey,
correct,
stage: item.stage,
intervalDays: item.intervalDays,
lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt),
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
correctCount: item.correctCount,
wrongCount: item.wrongCount,
lapseCount: item.lapseCount
};
}
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const query = typeof q === 'string' ? q.trim() : '';
// Abwärtskompatibel: falls alte Parameter genutzt werden, zusammenfassen
const learningTerm = typeof learning === 'string' ? learning.trim() : '';
const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : '';
const effective = query || learningTerm || motherTerm;
if (!effective) {
const err = new Error('Missing search term');
err.status = 400;
throw err;
}
const like = `%${effective}%`;
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "motherTongue"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
AND (l1.text ILIKE :like OR l2.text ILIKE :like)
ORDER BY l2.text ASC, l1.text ASC, c.title ASC
LIMIT 200
`,
{
replacements: {
languageId: access.id,
like,
},
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, results: rows };
}
/**
* Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert.
* Ein Suchbegriff durchsucht Lern- und Referenzspalte (Teilstrings, ILIKE).
*/
async getLanguageDictionary(hashedUserId, languageId, query = {}) {
const { q, page: pageParam, pageSize: pageSizeParam } = query;
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const term = typeof q === 'string' ? q.trim() : '';
const like = term ? `%${term}%` : null;
const { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam });
const baseReplacements = like ? { languageId: access.id, like } : { languageId: access.id };
const countRows = await sequelize.query(
`
SELECT COUNT(*)::integer AS n
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
`,
{
replacements: baseReplacements,
type: sequelize.QueryTypes.SELECT,
}
);
const total = countRows[0]?.n ?? 0;
const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize);
const effectivePage = Math.min(Math.max(1, page), totalPages);
const offset = (effectivePage - 1) * pageSize;
let rows = [];
if (total > 0) {
rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "reference"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
LIMIT :limit OFFSET :offset
`,
{
replacements: { ...baseReplacements, limit: pageSize, offset },
type: sequelize.QueryTypes.SELECT,
}
);
}
return {
languageId: access.id,
results: rows,
total,
page: effectivePage,
pageSize,
totalPages,
};
}
/**
* Wörterbuch: aus abgeschlossenen Kurslektionen extrahierte Paare, optional gefiltert (Teilstring in beiden Spalten).
*/
async getCourseDictionary(hashedUserId, courseId, query = {}) {
const { q, page: pageParam, pageSize: pageSizeParam } = query;
const pool = await this.getCompletedLessonVocabPool(hashedUserId, courseId);
const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
let vocabs = pool.vocabs || [];
if (term) {
vocabs = vocabs.filter((entry) => {
const l = String(entry.learning || '').toLowerCase();
const r = String(entry.reference || '').toLowerCase();
return l.includes(term) || r.includes(term);
});
}
vocabs.sort((a, b) => {
const refCmp = String(a.reference || '').localeCompare(String(b.reference || ''), undefined, { sensitivity: 'base' });
if (refCmp !== 0) return refCmp;
return String(a.learning || '').localeCompare(String(b.learning || ''), undefined, { sensitivity: 'base' });
});
const { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam });
const total = vocabs.length;
const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize);
const effectivePage = Math.min(Math.max(1, page), totalPages);
const offset = (effectivePage - 1) * pageSize;
const paged = vocabs.slice(offset, offset + pageSize);
return {
courseId: pool.courseId,
results: paged,
total,
page: effectivePage,
pageSize,
totalPages,
};
}
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
if (!ch.isOwner) {
const err = new Error('Only owner can add vocab');
err.status = 403;
throw err;
}
const learningText = typeof learning === 'string' ? learning.trim() : '';
const referenceText = typeof reference === 'string' ? reference.trim() : '';
if (!learningText || !referenceText) {
const err = new Error('Invalid vocab');
err.status = 400;
throw err;
}
const learningNorm = this._normalizeLexeme(learningText);
const referenceNorm = this._normalizeLexeme(referenceText);
// Transaktion: Lexeme upserten + Zuordnung setzen
return await sequelize.transaction(async (t) => {
const [learningLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: learningText, normalized: learningNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [referenceLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: referenceText, normalized: referenceNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [mapping] = await sequelize.query(
`
INSERT INTO community.vocab_chapter_lexeme (chapter_id, learning_lexeme_id, reference_lexeme_id, created_by_user_id)
VALUES (:chapterId, :learningId, :referenceId, :userId)
ON CONFLICT (chapter_id, learning_lexeme_id, reference_lexeme_id) DO NOTHING
RETURNING id
`,
{
replacements: {
chapterId: ch.id,
learningId: learningLex.id,
referenceId: referenceLex.id,
userId: user.id,
},
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
return { created: Boolean(mapping?.id) };
});
}
// ========== COURSE METHODS ==========
async createCourse(hashedUserId, { title, description, languageId, nativeLanguageId, difficultyLevel = 1, isPublic = false }) {
const user = await this._getUserByHashedId(hashedUserId);
// Prüfe Zugriff auf Sprache
await this._getLanguageAccess(user.id, languageId);
const shareCode = isPublic ? crypto.randomBytes(8).toString('hex') : null;
const course = await VocabCourse.create({
ownerUserId: user.id,
title,
description,
languageId: Number(languageId),
nativeLanguageId: nativeLanguageId ? Number(nativeLanguageId) : null,
difficultyLevel: Number(difficultyLevel) || 1,
isPublic: Boolean(isPublic),
shareCode
});
return course.get({ plain: true });
}
async getCourses(hashedUserId, { includePublic = true, includeOwn = true, languageId, nativeLanguageId, search } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
// Konvertiere String-Parameter zu Booleans
const includePublicBool = includePublic === 'true' || includePublic === true;
const includeOwnBool = includeOwn === 'true' || includeOwn === true;
const where = {};
const andConditions = [];
// Zugriffsbedingungen
if (includeOwnBool && includePublicBool) {
andConditions.push({
[Op.or]: [
{ ownerUserId: user.id },
{ isPublic: true }
]
});
} else if (includeOwnBool) {
where.ownerUserId = user.id;
} else if (includePublicBool) {
where.isPublic = true;
}
// Filter nach Zielsprache (die zu lernende Sprache)
if (languageId) {
where.languageId = Number(languageId);
}
// Filter nach Muttersprache (die Sprache des Lerners)
// Wenn nativeLanguageId nicht gesetzt ist (undefined), zeige alle Kurse (kein Filter)
// Wenn nativeLanguageId === null oder 'null' (aus Frontend), zeige alle Kurse (kein Filter)
// Wenn nativeLanguageId eine Zahl ist, zeige nur Kurse für diese Muttersprache
if (nativeLanguageId !== undefined && nativeLanguageId !== null && nativeLanguageId !== 'null') {
where.nativeLanguageId = Number(nativeLanguageId);
}
// Wenn nativeLanguageId null/undefined/'null' ist, wird kein Filter angewendet = alle Kurse
// Suche nach Titel oder Beschreibung
if (search && search.trim()) {
const searchTerm = `%${search.trim()}%`;
andConditions.push({
[Op.or]: [
{ title: { [Op.iLike]: searchTerm } },
{ description: { [Op.iLike]: searchTerm } }
]
});
}
// Kombiniere alle AND-Bedingungen
// Wenn sowohl andConditions als auch direkte where-Eigenschaften existieren,
// müssen sie kombiniert werden
// WICHTIG: directWhereProps muss NACH dem Setzen aller direkten Eigenschaften berechnet werden
const directWhereProps = Object.keys(where).filter(key => {
// Filtere Op.and und Op.or heraus (sind Symbol-Keys)
return key !== Op.and && key !== Op.or && typeof key === 'string';
});
if (andConditions.length > 0) {
// Wenn where bereits direkte Eigenschaften hat, füge sie zu andConditions hinzu
if (directWhereProps.length > 0) {
const directWhere = {};
for (const key of directWhereProps) {
directWhere[key] = where[key];
delete where[key];
}
andConditions.push(directWhere);
}
// Entferne leere Objekte aus andConditions
const filteredConditions = andConditions.filter(cond => {
return cond && typeof cond === 'object' && Object.keys(cond).length > 0;
});
// Setze andConditions nur, wenn sie nicht leer sind
if (filteredConditions.length > 0) {
where[Op.and] = filteredConditions;
}
}
// Wenn nur direkte Eigenschaften existieren (andConditions.length === 0),
// bleiben sie in where (nichts zu tun, sie sind bereits dort)
const courses = await VocabCourse.findAll({
where,
order: [['createdAt', 'DESC']]
});
const coursesData = courses.map(c => c.get({ plain: true }));
await this._attachLanguageNamesToCourseRows(coursesData);
return coursesData;
}
async getCourseByShareCode(hashedUserId, shareCode) {
const user = await this._getUserByHashedId(hashedUserId);
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
if (!code || code.length < 6 || code.length > 128) {
const err = new Error('Invalid share code');
err.status = 400;
throw err;
}
const course = await VocabCourse.findOne({
where: { shareCode: code }
});
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff (öffentlich oder Besitzer)
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Course is not public');
err.status = 403;
throw err;
}
return course.get({ plain: true });
}
async getCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId, {
include: [
{
model: VocabCourseLesson,
as: 'lessons',
order: [['lessonNumber', 'ASC']]
}
]
});
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const courseData = course.get({ plain: true });
courseData.lessons = courseData.lessons || [];
// Sortiere Lektionen nach Woche, Tag, dann Nummer
courseData.lessons.sort((a, b) => {
if (a.weekNumber !== b.weekNumber) {
return (a.weekNumber || 999) - (b.weekNumber || 999);
}
if (a.dayNumber !== b.dayNumber) {
return (a.dayNumber || 999) - (b.dayNumber || 999);
}
return a.lessonNumber - b.lessonNumber;
});
courseData.lessons = courseData.lessons.map((lesson) => ({
...lesson,
pedagogy: this._buildLessonPedagogy(lesson)
}));
await this._attachLanguageNamesToCourseRows([courseData]);
return courseData;
}
/** Admin/Support: Kurs inkl. Lektionen ohne Sichtbarkeitsprüfung (nur serverseitig für Staff-Routen). */
async adminGetCourseWithLessonsForStaff(courseId) {
const course = await VocabCourse.findByPk(Number(courseId), {
include: [
{
model: VocabCourseLesson,
as: 'lessons',
order: [['lessonNumber', 'ASC']]
}
]
});
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
const courseData = course.get({ plain: true });
courseData.lessons = courseData.lessons || [];
courseData.lessons.sort((a, b) => {
if (a.weekNumber !== b.weekNumber) {
return (a.weekNumber || 999) - (b.weekNumber || 999);
}
if (a.dayNumber !== b.dayNumber) {
return (a.dayNumber || 999) - (b.dayNumber || 999);
}
return a.lessonNumber - b.lessonNumber;
});
courseData.lessons = courseData.lessons.map((lesson) => ({
...lesson,
pedagogy: this._buildLessonPedagogy(lesson)
}));
return courseData;
}
async updateCourse(hashedUserId, courseId, { title, description, languageId, nativeLanguageId, difficultyLevel, isPublic }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update the course');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (languageId !== undefined) updates.languageId = Number(languageId);
if (nativeLanguageId !== undefined) updates.nativeLanguageId = nativeLanguageId ? Number(nativeLanguageId) : null;
if (difficultyLevel !== undefined) updates.difficultyLevel = Number(difficultyLevel);
if (isPublic !== undefined) {
updates.isPublic = Boolean(isPublic);
// Generiere Share-Code wenn Kurs öffentlich wird
if (isPublic && !course.shareCode) {
updates.shareCode = crypto.randomBytes(8).toString('hex');
} else if (!isPublic) {
updates.shareCode = null;
}
}
await course.update(updates);
return course.get({ plain: true });
}
async deleteCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete the course');
err.status = 403;
throw err;
}
await course.destroy();
return { success: true };
}
_seededShuffle(items, seed) {
const arr = items.slice();
let t = (Number(seed) >>> 0) ^ 0x6a09e667;
const rnd = () => {
t ^= t << 13;
t ^= t >>> 17;
t ^= t << 5;
return (t >>> 0) / 4294967296;
};
for (let i = arr.length - 1; i > 0; i -= 1) {
const j = Math.floor(rnd() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
_buildDeterministicChapterLexemeMcOptions(correct, distractorSource, seed) {
const norm = (s) => this._normalizeTextAnswer(s);
const nc = norm(correct);
const filtered = distractorSource.filter((t) => norm(t) !== nc && String(t || '').trim());
const shuffled = this._seededShuffle(filtered, (seed ^ 0x9e3779b9) >>> 0);
const picks = [];
for (const p of shuffled) {
if (picks.length >= 3) {
break;
}
picks.push(p);
}
let pad = 1;
while (picks.length < 3) {
picks.push(`(${pad})`);
pad += 1;
}
const ordered = this._seededShuffle([correct, ...picks], seed >>> 0);
const correctAnswer = ordered.findIndex((o) => String(o) === String(correct));
return { options: ordered, correctAnswer: correctAnswer >= 0 ? correctAnswer : 0 };
}
_lexemePairCoveredByMultipleChoice(exerciseList, learning, reference) {
const nl = this._normalizeTextAnswer(learning);
const nr = this._normalizeTextAnswer(reference);
if (!nl || !nr) {
return false;
}
for (const ex of exerciseList) {
if (Number(ex.exerciseTypeId) !== 2) {
continue;
}
const qd = typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData;
if (!qd || qd.type !== 'multiple_choice') {
continue;
}
const prompt = this._normalizeTextAnswer(qd.question || qd.text || '');
if (!prompt.includes(nl)) {
continue;
}
const ad = typeof ex.answerData === 'string' ? JSON.parse(ex.answerData) : ex.answerData;
const options = Array.isArray(qd.options) ? qd.options : [];
let idx = ad?.correctAnswer;
if (Array.isArray(idx)) {
idx = idx[0];
}
if (idx === undefined && ad?.correct !== undefined) {
idx = Array.isArray(ad.correct) ? ad.correct[0] : ad.correct;
}
if (idx === undefined || options[Number(idx)] === undefined) {
continue;
}
const correctOpt = this._normalizeTextAnswer(options[Number(idx)]);
if (correctOpt === nr) {
return true;
}
}
return false;
}
_buildSyntheticLexemeMcExercisePlain(lessonId, row, exerciseNumber) {
const learning = String(row.learning || '').trim();
const reference = String(row.reference || '').trim();
if (!learning || !reference) {
return null;
}
const seed = (Number(row.id) * 100003 + Number(lessonId)) >>> 0;
const { options, correctAnswer } = this._buildDeterministicChapterLexemeMcOptions(
reference,
row.allReferences || [],
seed
);
const questionData = {
type: 'multiple_choice',
question: `Was bedeutet „${learning}“?`,
options,
randomizeDistractors: false
};
const answerData = {
type: 'multiple_choice',
correctAnswer
};
return {
id: `syn-${lessonId}-${row.id}-l2r`,
lessonId,
exerciseTypeId: 2,
exerciseType: { id: 2, name: 'multiple_choice' },
exerciseNumber,
title: `Kapitel-Vokabel: ${learning}`,
instruction: 'Wähle die passende Übersetzung.',
questionData,
answerData,
explanation: null,
createdByUserId: 0
};
}
async _fetchChapterLexemeRowsForMc(chapterId) {
const id = Number.parseInt(chapterId, 10);
if (!Number.isFinite(id)) {
return [];
}
const rows = await sequelize.query(
`
SELECT
cl.id,
l1.text AS learning,
l2.text AS reference
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE cl.chapter_id = :chapterId
ORDER BY cl.id ASC
`,
{
replacements: { chapterId: id },
type: sequelize.QueryTypes.SELECT
}
);
return rows;
}
async _mergeSyntheticChapterLexemeMcExercises(plainLesson, grammarExercises) {
const list = Array.isArray(grammarExercises) ? [...grammarExercises] : [];
if (!plainLesson?.chapterId) {
return list;
}
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') {
return list;
}
const rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId);
if (!rows.length) {
return list;
}
const allReferences = rows.map((r) => r.reference).filter(Boolean);
let maxNum = list.reduce((m, ex) => Math.max(m, Number(ex.exerciseNumber) || 0), 0);
const augmentedRows = rows.map((r) => ({ ...r, allReferences }));
for (const row of augmentedRows) {
if (this._lexemePairCoveredByMultipleChoice(list, row.learning, row.reference)) {
continue;
}
maxNum += 1;
const ex = this._buildSyntheticLexemeMcExercisePlain(plainLesson.id, row, maxNum);
if (ex) {
list.push(ex);
}
}
return list;
}
async _checkSyntheticLexemeMcAnswer(user, lessonId, chapterLexemeId, userAnswer) {
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
if (!lesson.chapterId) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
const rows = await this._fetchChapterLexemeRowsForMc(lesson.chapterId);
const row = rows.find((r) => Number(r.id) === Number(chapterLexemeId));
if (!row) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
const learning = String(row.learning || '').trim();
const reference = String(row.reference || '').trim();
if (!learning || !reference) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
const allReferences = rows.map((r) => r.reference).filter(Boolean);
const seed = (Number(row.id) * 100003 + Number(lessonId)) >>> 0;
const { options, correctAnswer } = this._buildDeterministicChapterLexemeMcOptions(reference, allReferences, seed);
const questionData = {
type: 'multiple_choice',
question: `Was bedeutet „${learning}“?`,
options,
randomizeDistractors: false
};
const answerData = {
type: 'multiple_choice',
correctAnswer
};
const isCorrect = this._checkAnswer(answerData, questionData, userAnswer, 2);
const correctIdx = Number(correctAnswer);
const correctAnswerText = options[correctIdx];
const alternatives = options.filter((_, idx) => idx !== correctIdx);
return {
correct: isCorrect,
correctAnswer: correctAnswerText || null,
alternatives,
explanation: null,
progress: {
attempts: 1,
correctAttempts: isCorrect ? 1 : 0,
lastAttemptAt: new Date(),
completed: Boolean(isCorrect),
completedAt: isCorrect ? new Date() : null
}
};
}
async getLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [
{
model: VocabCourse,
as: 'course'
},
{
model: VocabGrammarExercise,
as: 'grammarExercises',
include: [
{
model: VocabGrammarExerciseType,
as: 'exerciseType'
}
],
required: false,
separate: true,
order: [['exerciseNumber', 'ASC']]
}
]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const progress = await VocabCourseProgress.findOne({
where: {
userId: user.id,
lessonId: lesson.id
}
});
const plainLesson = lesson.get({ plain: true });
// Lade Vokabeln aus vorherigen Lektionen (für Wiederholung UND für gemischten Vokabeltrainer)
if (plainLesson.lessonNumber > 1) {
plainLesson.previousLessonExercises = await this._getReviewVocabExercises(plainLesson.courseId, plainLesson.lessonNumber);
}
// Bei Wiederholungslektionen: Auch Lektions-Liste für Anzeige
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') {
plainLesson.reviewLessons = await this._getReviewLessons(plainLesson.courseId, plainLesson.lessonNumber);
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
}
plainLesson.grammarExercises = await this._mergeSyntheticChapterLexemeMcExercises(
plainLesson,
plainLesson.grammarExercises || []
);
const suppressLessonReviewDue = await this._courseHasDueSrsItems(user.id, plainLesson.courseId);
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson);
plainLesson.progress = this._serializeLessonProgress(progress, plainLesson, { suppressLessonReviewDue });
return plainLesson;
}
async sendLessonAssistantMessage(hashedUserId, lessonId, payload = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await this.getLesson(hashedUserId, lessonId);
const config = await this._getUserLlmConfig(user.id);
if (!config.enabled) {
const err = new Error('Der Sprachassistent ist in deinen Einstellungen derzeit deaktiviert.');
err.status = 400;
throw err;
}
if (!config.configured) {
const err = new Error('Der Sprachassistent ist noch nicht eingerichtet. Bitte hinterlege zuerst Modell und API-Zugang in den Einstellungen.');
err.status = 400;
throw err;
}
const message = String(payload?.message || '').trim();
if (!message) {
const err = new Error('Bitte gib eine Nachricht für den Sprachassistenten ein.');
err.status = 400;
throw err;
}
const mode = ['explain', 'practice', 'correct'].includes(payload?.mode) ? payload.mode : 'practice';
const history = this._sanitizeAssistantHistory(payload?.history);
const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
const headers = {
'Content-Type': 'application/json'
};
if (config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
let response;
try {
response = await fetch(endpoint, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
model: config.model,
temperature: 0.7,
messages: [
{
role: 'system',
content: this._buildLessonAssistantSystemPrompt(lesson, mode)
},
...history,
{
role: 'user',
content: message
}
]
})
});
} catch (error) {
const err = new Error(
error?.name === 'AbortError'
? 'Der Sprachassistent hat zu lange für eine Antwort gebraucht.'
: 'Der Sprachassistent konnte nicht erreicht werden.'
);
err.status = 502;
throw err;
} finally {
clearTimeout(timeout);
}
let responseData = null;
try {
responseData = await response.json();
} catch {
responseData = null;
}
if (!response.ok) {
const messageFromApi = responseData?.error?.message || responseData?.message || 'Der Sprachassistent hat die Anfrage abgelehnt.';
const err = new Error(messageFromApi);
err.status = response.status || 502;
throw err;
}
const reply = this._extractAssistantContent(responseData);
if (!reply) {
const err = new Error('Der Sprachassistent hat keine verwertbare Antwort geliefert.');
err.status = 502;
throw err;
}
return {
reply,
model: responseData?.model || config.model,
mode
};
}
/**
* Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
*/
async _getReviewLessons(courseId, currentLessonNumber) {
const lessons = await VocabCourseLesson.findAll({
where: {
courseId: courseId,
lessonNumber: {
[Op.lt]: currentLessonNumber // Nur Lektionen mit kleinerer Nummer
},
lessonType: {
[Op.notIn]: ['review', 'vocab_review'] // Keine anderen Wiederholungslektionen
}
},
order: [['lessonNumber', 'ASC']],
attributes: ['id', 'lessonNumber', 'title']
});
return lessons.map(l => l.get({ plain: true }));
}
/**
* Sammelt alle Grammatik-Übungen aus vorherigen Lektionen für Wiederholungslektionen
*/
async _getReviewVocabExercises(courseId, currentLessonNumber) {
const previousLessons = await VocabCourseLesson.findAll({
where: {
courseId: courseId,
lessonNumber: {
[Op.lt]: currentLessonNumber
},
lessonType: {
[Op.notIn]: ['review', 'vocab_review']
}
},
attributes: ['id']
});
if (previousLessons.length === 0) {
return [];
}
const lessonIds = previousLessons.map(l => l.id);
const exercises = await VocabGrammarExercise.findAll({
where: {
lessonId: {
[Op.in]: lessonIds
}
},
include: [
{
model: VocabGrammarExerciseType,
as: 'exerciseType'
},
{
model: VocabCourseLesson,
as: 'lesson',
attributes: ['id', 'lessonNumber', 'title']
}
],
order: [
[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC'],
['exerciseNumber', 'ASC']
]
});
return exercises.map(e => e.get({ plain: true }));
}
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can add lessons');
err.status = 403;
throw err;
}
// Prüfe, ob Kapitel zur gleichen Sprache gehört (nur wenn chapterId angegeben)
if (chapterId) {
const [chapter] = await sequelize.query(
`SELECT language_id FROM community.vocab_chapter WHERE id = :chapterId`,
{
replacements: { chapterId: Number(chapterId) },
type: sequelize.QueryTypes.SELECT
}
);
if (!chapter || chapter.language_id !== course.languageId) {
const err = new Error('Chapter does not belong to the course language');
err.status = 400;
throw err;
}
}
const lesson = await VocabCourseLesson.create({
courseId: course.id,
chapterId: chapterId ? Number(chapterId) : null,
lessonNumber: Number(lessonNumber),
title,
description,
weekNumber: weekNumber ? Number(weekNumber) : null,
dayNumber: dayNumber ? Number(dayNumber) : null,
lessonType: lessonType || 'vocab',
didacticMode: this._normalizeOptionalString(didacticMode),
phaseLabel: this._normalizeOptionalString(phaseLabel),
blockNumber: this._normalizeOptionalInteger(blockNumber),
difficultyWeight: this._normalizeOptionalInteger(difficultyWeight),
newUnitTarget: this._normalizeOptionalInteger(newUnitTarget),
reviewWeight: this._normalizeOptionalInteger(reviewWeight),
isIntensiveReview: isIntensiveReview !== undefined ? Boolean(isIntensiveReview) : false,
audioUrl: audioUrl || null,
culturalNotes: culturalNotes || null,
learningGoals: this._normalizeStringList(learningGoals),
corePatterns: this._normalizeCorePatternList(corePatterns),
grammarFocus: this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']),
speakingPrompts: this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']),
practicalTasks: this._normalizeStructuredList(practicalTasks, ['title', 'text']),
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
});
return lesson.get({ plain: true });
}
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update lessons');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (lessonNumber !== undefined) updates.lessonNumber = Number(lessonNumber);
if (weekNumber !== undefined) updates.weekNumber = weekNumber ? Number(weekNumber) : null;
if (dayNumber !== undefined) updates.dayNumber = dayNumber ? Number(dayNumber) : null;
if (lessonType !== undefined) updates.lessonType = lessonType;
if (didacticMode !== undefined) updates.didacticMode = this._normalizeOptionalString(didacticMode);
if (phaseLabel !== undefined) updates.phaseLabel = this._normalizeOptionalString(phaseLabel);
if (blockNumber !== undefined) updates.blockNumber = this._normalizeOptionalInteger(blockNumber);
if (difficultyWeight !== undefined) updates.difficultyWeight = this._normalizeOptionalInteger(difficultyWeight);
if (newUnitTarget !== undefined) updates.newUnitTarget = this._normalizeOptionalInteger(newUnitTarget);
if (reviewWeight !== undefined) updates.reviewWeight = this._normalizeOptionalInteger(reviewWeight);
if (isIntensiveReview !== undefined) updates.isIntensiveReview = Boolean(isIntensiveReview);
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
if (learningGoals !== undefined) updates.learningGoals = this._normalizeStringList(learningGoals);
if (corePatterns !== undefined) updates.corePatterns = this._normalizeCorePatternList(corePatterns);
if (grammarFocus !== undefined) updates.grammarFocus = this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']);
if (speakingPrompts !== undefined) updates.speakingPrompts = this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']);
if (practicalTasks !== undefined) updates.practicalTasks = this._normalizeStructuredList(practicalTasks, ['title', 'text']);
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
await lesson.update(updates);
return lesson.get({ plain: true });
}
async deleteLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete lessons');
err.status = 403;
throw err;
}
await lesson.destroy();
return { success: true };
}
async enrollInCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Course is not public');
err.status = 403;
throw err;
}
const [enrollment, created] = await VocabCourseEnrollment.findOrCreate({
where: { userId: user.id, courseId: course.id },
defaults: { userId: user.id, courseId: course.id }
});
if (!created) {
const err = new Error('Already enrolled in this course');
err.status = 400;
throw err;
}
return enrollment.get({ plain: true });
}
async unenrollFromCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 404;
throw err;
}
await enrollment.destroy();
return { success: true };
}
async getMyCourses(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [{ model: VocabCourse, as: 'course' }],
order: [['enrolledAt', 'DESC']]
});
return enrollments.map(e => ({
...e.course.get({ plain: true }),
enrolledAt: e.enrolledAt
}));
}
/**
* Kompakte Übersicht für das Start-Dashboard: eingeschriebene Kurse und „aktuelle“ Lektion
* (gleiche Logik wie VocabCourseView.currentLesson: erste unvollständige, sonst letzte).
*/
async getDashboardLearningSummary(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [
{
model: VocabCourse,
as: 'course',
required: true,
attributes: ['id', 'title']
}
],
order: [['enrolledAt', 'DESC']]
});
const courseById = new Map();
for (const e of enrollments) {
const c = e.course?.get({ plain: true });
if (!c?.id || courseById.has(c.id)) {
continue;
}
courseById.set(c.id, { id: c.id, title: c.title || '' });
}
const coursesMeta = [...courseById.values()];
if (coursesMeta.length === 0) {
return { courses: [] };
}
const courseIds = coursesMeta.map((c) => c.id);
const lessons = await VocabCourseLesson.findAll({
where: { courseId: { [Op.in]: courseIds } },
attributes: ['id', 'courseId', 'lessonNumber', 'title'],
order: [
['courseId', 'ASC'],
['lessonNumber', 'ASC']
]
});
const progressRows = await VocabCourseProgress.findAll({
where: { userId: user.id, courseId: { [Op.in]: courseIds } },
attributes: ['lessonId', 'completed']
});
const completedByLessonId = new Map();
for (const row of progressRows) {
const plain = row.get({ plain: true });
completedByLessonId.set(plain.lessonId, Boolean(plain.completed));
}
const lessonsByCourse = new Map();
for (const row of lessons) {
const plain = row.get({ plain: true });
const list = lessonsByCourse.get(plain.courseId) || [];
list.push(plain);
lessonsByCourse.set(plain.courseId, list);
}
const courses = [];
for (const meta of coursesMeta) {
const sorted = lessonsByCourse.get(meta.id) || [];
if (sorted.length === 0) {
courses.push({
courseId: meta.id,
title: meta.title,
currentLesson: null,
allLessonsCompleted: false
});
continue;
}
let current = null;
for (const lesson of sorted) {
if (!completedByLessonId.get(lesson.id)) {
current = lesson;
break;
}
}
if (!current) {
current = sorted[sorted.length - 1];
}
const allLessonsCompleted = sorted.every((lesson) => completedByLessonId.get(lesson.id) === true);
courses.push({
courseId: meta.id,
title: meta.title,
currentLesson: {
id: current.id,
lessonNumber: current.lessonNumber,
title: current.title || ''
},
allLessonsCompleted
});
}
return { courses };
}
/**
* Kurse, in die der Nutzer (per Hash) eingeschrieben ist — jede courseId nur einmal,
* bei mehrfachen Einschreibungen zählt die jeweils neueste Zeile.
*/
async listEnrolledVocabCoursesForUser(targetHashedUserId) {
const user = await this._getUserByHashedId(targetHashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [{ model: VocabCourse, as: 'course', required: true }],
order: [['enrolledAt', 'DESC']]
});
const byCourseId = new Map();
for (const e of enrollments) {
const row = e.course;
if (!row) {
continue;
}
const plain = row.get({ plain: true });
if (byCourseId.has(plain.id)) {
continue;
}
byCourseId.set(plain.id, {
...plain,
enrolledAt: e.enrolledAt
});
}
const coursesData = [...byCourseId.values()];
await this._attachLanguageNamesToCourseRows(coursesData);
return coursesData;
}
async getCourseProgress(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const progress = await VocabCourseProgress.findAll({
where: { userId: user.id, courseId: Number(courseId) },
include: [{ model: VocabCourseLesson, as: 'lesson' }],
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']]
});
const suppressLessonReviewDue = await this._courseHasDueSrsItems(user.id, courseId);
return progress.map((entry) => this._serializeLessonProgress(entry, entry.lesson, { suppressLessonReviewDue }));
}
async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes, lessonState }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const lessonData = await VocabCourseLesson.findByPk(lesson.id);
const targetScore = lessonData.targetScorePercent || 80;
const actualScore = Number(score) || 0;
const hasReachedTarget = actualScore >= targetScore;
const sanitizedLessonState = lessonState === undefined ? undefined : this._sanitizeLessonState(lessonState);
const didSubmitResult = completed !== undefined || score !== undefined;
// Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true)
const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false);
const [progress, created] = await VocabCourseProgress.findOrCreate({
where: { userId: user.id, lessonId: lesson.id },
defaults: {
userId: user.id,
courseId: lesson.courseId,
lessonId: lesson.id,
completed: isCompleted,
score: actualScore,
lessonState: sanitizedLessonState || {},
lastAccessedAt: new Date()
}
});
if (!created) {
const updates = { lastAccessedAt: new Date() };
if (score !== undefined) {
updates.score = Math.max(progress.score, actualScore);
// Prüfe, ob Ziel jetzt erreicht wurde
if (updates.score >= targetScore && !progress.completed) {
if (!lessonData.requiresReview) {
updates.completed = true;
updates.completedAt = new Date();
}
}
}
if (completed !== undefined) {
updates.completed = Boolean(completed);
if (completed && !progress.completedAt) {
updates.completedAt = new Date();
}
}
if (sanitizedLessonState !== undefined) {
updates.lessonState = sanitizedLessonState;
}
const nextCompleted = updates.completed !== undefined ? Boolean(updates.completed) : Boolean(progress.completed);
const mergedLessonState = {
...this._sanitizeLessonState(progress.lessonState),
...(updates.lessonState || {})
};
updates.lessonState = this._applyScheduledReviewState(mergedLessonState, {
previousCompleted: Boolean(progress.completed),
nextCompleted,
shouldAdvanceReview: didSubmitResult && nextCompleted,
lessonData,
now: updates.completedAt || updates.lastAccessedAt || new Date()
});
await progress.update(updates);
} else if (isCompleted) {
progress.completed = true;
progress.completedAt = new Date();
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState || {}, {
previousCompleted: false,
nextCompleted: true,
shouldAdvanceReview: didSubmitResult,
lessonData,
now: progress.completedAt
});
await progress.save();
} else if (sanitizedLessonState !== undefined) {
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState, {
previousCompleted: false,
nextCompleted: false,
shouldAdvanceReview: false,
lessonData,
now: new Date()
});
await progress.save();
}
return this._serializeLessonProgress(progress, lessonData);
}
/**
* Löscht nur den Fortschritt zu einer Lektion (Zeile vocab_course_progress + zugehörige grammar-exercise-progress).
* Gesamtkurs / andere Lektionen bleiben unberührt.
*/
async _purgeLessonProgressForUser(userId, lessonId) {
const numericLessonId = Number(lessonId);
const exercises = await VocabGrammarExercise.findAll({
where: { lessonId: numericLessonId },
attributes: ['id']
});
const exerciseIds = exercises.map((e) => e.id);
let deletedExerciseProgressRows = 0;
if (exerciseIds.length > 0) {
deletedExerciseProgressRows = await VocabGrammarExerciseProgress.destroy({
where: { userId, exerciseId: { [Op.in]: exerciseIds } }
});
}
const deletedLessonProgressRows = await VocabCourseProgress.destroy({
where: { userId, lessonId: numericLessonId }
});
return {
success: true,
lessonId: numericLessonId,
deletedLessonProgressRows,
deletedExerciseProgressRows
};
}
/** Eingeloggter Nutzer setzt eigene Lektion zurück (nur bei Kurs-Einschreibung). */
async resetMyLessonProgress(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
return this._purgeLessonProgressForUser(user.id, lesson.id);
}
/** Admin: Zielnutzer per Hash, ohne Einschreibungszwang (idempotentes Löschen). */
async adminResetLessonProgressForUser(targetHashedUserId, lessonId) {
const user = await this._getUserByHashedId(targetHashedUserId);
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
return this._purgeLessonProgressForUser(user.id, lesson.id);
}
/**
* Admin: Alle Lektionen eines Kurses bis einschließlich lesson_number als abgeschlossen setzen
* (nur Zeilen, die noch nicht completed sind). Nur bei eingeschriebenem Nutzer.
*/
async adminMarkLessonsCompleteThrough(targetHashedUserId, courseId, throughLessonNumber) {
const user = await this._getUserByHashedId(targetHashedUserId);
const cid = Number(courseId);
const maxNum = Number(throughLessonNumber);
if (!Number.isFinite(cid) || cid < 1) {
const err = new Error('Invalid courseId');
err.status = 400;
throw err;
}
if (!Number.isFinite(maxNum) || maxNum < 1) {
const err = new Error('Invalid throughLessonNumber');
err.status = 400;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: cid }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const lessons = await VocabCourseLesson.findAll({
where: { courseId: cid, lessonNumber: { [Op.lte]: maxNum } },
order: [['lessonNumber', 'ASC']]
});
const now = new Date();
const details = [];
for (const lesson of lessons) {
const lessonData = lesson.get({ plain: true });
const targetScore = lessonData.targetScorePercent || 80;
const [progress] = await VocabCourseProgress.findOrCreate({
where: { userId: user.id, lessonId: lesson.id },
defaults: {
userId: user.id,
courseId: cid,
lessonId: lesson.id,
completed: false,
score: 0,
lessonState: {}
}
});
if (progress.completed) {
details.push({
lessonNumber: lesson.lessonNumber,
lessonId: lesson.id,
status: 'unchanged'
});
continue;
}
const mergedState = this._applyScheduledReviewState(
this._sanitizeLessonState(progress.lessonState),
{
previousCompleted: false,
nextCompleted: true,
shouldAdvanceReview: true,
lessonData,
now
}
);
await progress.update({
completed: true,
completedAt: now,
score: Math.max(Number(progress.score) || 0, targetScore),
lastAccessedAt: now,
lessonState: mergedState
});
details.push({
lessonNumber: lesson.lessonNumber,
lessonId: lesson.id,
status: 'marked_complete'
});
}
return {
courseId: cid,
throughLessonNumber: maxNum,
lessonsConsidered: lessons.length,
details
};
}
// ========== GRAMMAR EXERCISE METHODS ==========
async getExerciseTypes() {
const types = await VocabGrammarExerciseType.findAll({
order: [['name', 'ASC']]
});
return types.map(t => t.get({ plain: true }));
}
async createGrammarExercise(hashedUserId, lessonId, { exerciseTypeId, exerciseNumber, title, instruction, questionData, answerData, explanation }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe, ob User Besitzer des Kurses ist
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can add grammar exercises');
err.status = 403;
throw err;
}
const exercise = await VocabGrammarExercise.create({
lessonId: lesson.id,
exerciseTypeId: Number(exerciseTypeId),
exerciseNumber: Number(exerciseNumber),
title,
instruction,
questionData,
answerData,
explanation,
createdByUserId: user.id
});
return exercise.get({ plain: true });
}
async getGrammarExercisesForLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const exercises = await VocabGrammarExercise.findAll({
where: { lessonId: lesson.id },
include: [{ model: VocabGrammarExerciseType, as: 'exerciseType' }],
order: [['exerciseNumber', 'ASC']]
});
const plainLesson = lesson.get({ plain: true });
const list = exercises.map((e) => e.get({ plain: true }));
return await this._mergeSyntheticChapterLexemeMcExercises(plainLesson, list);
}
async getGrammarExercise(hashedUserId, exerciseId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] },
{ model: VocabGrammarExerciseType, as: 'exerciseType' }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (exercise.lesson.course.ownerUserId !== user.id && !exercise.lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
return exercise.get({ plain: true });
}
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
const user = await this._getUserByHashedId(hashedUserId);
const exIdStr = String(exerciseId ?? '');
const synMatch = /^syn-(\d+)-(\d+)-l2r$/.exec(exIdStr);
if (synMatch) {
return this._checkSyntheticLexemeMcAnswer(
user,
Number(synMatch[1]),
Number(synMatch[2]),
userAnswer
);
}
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: exercise.lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const originalAnswerData = typeof exercise.answerData === 'string'
? JSON.parse(exercise.answerData)
: exercise.answerData;
const questionData = typeof exercise.questionData === 'string'
? JSON.parse(exercise.questionData)
: exercise.questionData;
const effectiveAnswerData = exercise.exerciseTypeId === 2
? await this._expandMultipleChoiceAnswerData(exercise, originalAnswerData, questionData)
: originalAnswerData;
// Überprüfe Antwort
const isCorrect = this._checkAnswer(effectiveAnswerData, questionData, userAnswer, exercise.exerciseTypeId);
// Speichere Fortschritt
const [progress, created] = await VocabGrammarExerciseProgress.findOrCreate({
where: { userId: user.id, exerciseId: exercise.id },
defaults: {
userId: user.id,
exerciseId: exercise.id,
attempts: 1,
correctAttempts: isCorrect ? 1 : 0,
lastAttemptAt: new Date(),
completed: false
}
});
if (!created) {
progress.attempts += 1;
if (isCorrect) {
progress.correctAttempts += 1;
if (!progress.completed) {
progress.completed = true;
progress.completedAt = new Date();
}
}
progress.lastAttemptAt = new Date();
await progress.save();
} else if (isCorrect) {
progress.completed = true;
progress.completedAt = new Date();
await progress.save();
}
// Extrahiere richtige Antwort und Alternativen
const answerData = effectiveAnswerData;
let correctAnswer = null;
let alternatives = [];
// Für Multiple Choice: Extrahiere die richtige(n) Antwort(en) aus dem Index/den Indizes
if (exercise.exerciseTypeId === 2 && answerData.correctAnswer !== undefined) {
const options = questionData?.options || [];
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
let correctIndices = [];
if (Array.isArray(answerData.correctAnswer)) {
correctIndices = answerData.correctAnswer.map(idx => Number(idx));
} else {
correctIndices = [Number(answerData.correctAnswer)];
}
// Extrahiere alle korrekten Antworten
const correctAnswersList = correctIndices
.map(idx => options[idx])
.filter(opt => opt !== undefined);
if (correctAnswersList.length > 0) {
// Wenn mehrere richtige Antworten: Zeige alle an, getrennt durch " / "
correctAnswer = correctAnswersList.join(' / ');
}
// Alternativen sind alle anderen Optionen (nicht korrekte)
alternatives = options.filter((opt, idx) => !correctIndices.includes(idx));
}
// Für Gap Fill: Extrahiere aus answers Array
else if (exercise.exerciseTypeId === 1 && answerData.answers) {
correctAnswer = Array.isArray(answerData.answers)
? answerData.answers.join(', ')
: answerData.answers;
}
// Für Reading Aloud: Extrahiere den erwarteten Text
else if (questionData.type === 'reading_aloud') {
correctAnswer = questionData.text || answerData.expectedText || '';
}
// Für Speaking From Memory: Extrahiere erwarteten Text oder Schlüsselwörter
else if (questionData.type === 'speaking_from_memory') {
correctAnswer = questionData.expectedText || questionData.text || '';
alternatives = questionData.keywords || [];
}
else if (questionData.type === 'sentence_building' || questionData.type === 'dialog_completion' || questionData.type === 'situational_response' || questionData.type === 'pattern_drill') {
const rawCorrect = answerData.correct ?? answerData.correctAnswer ?? answerData.answers ?? answerData.modelAnswer;
if (Array.isArray(rawCorrect)) {
correctAnswer = rawCorrect.join(' / ');
} else {
correctAnswer = rawCorrect || questionData.modelAnswer || '';
}
alternatives = answerData.alternatives || questionData.keywords || [];
}
// Fallback: Versuche correct oder correctAnswer
else {
correctAnswer = Array.isArray(answerData.correct)
? answerData.correct[0]
: (answerData.correct || answerData.correctAnswer);
alternatives = answerData.alternatives || [];
}
return {
correct: isCorrect,
correctAnswer: correctAnswer,
alternatives: alternatives,
explanation: exercise.explanation,
progress: progress.get({ plain: true })
};
}
async _getExerciseTypeIdByName(typeName) {
const type = await VocabGrammarExerciseType.findOne({ where: { name: typeName } });
return type ? type.id : null;
}
_extractMultipleChoiceIndices(answerData) {
if (!answerData) return [];
if (answerData.correctAnswer !== undefined) {
return Array.isArray(answerData.correctAnswer)
? answerData.correctAnswer.map(idx => Number(idx)).filter(Number.isInteger)
: [Number(answerData.correctAnswer)].filter(Number.isInteger);
}
if (answerData.correct !== undefined) {
return Array.isArray(answerData.correct)
? answerData.correct.map(idx => Number(idx)).filter(Number.isInteger)
: [Number(answerData.correct)].filter(Number.isInteger);
}
return [];
}
_getMultipleChoicePrompt(questionData) {
return this._normalizeTextAnswer(
questionData?.question || questionData?.text || questionData?.prompt || ''
);
}
async _expandMultipleChoiceAnswerData(exercise, answerData, questionData) {
const options = Array.isArray(questionData?.options) ? questionData.options : [];
const baseIndices = this._extractMultipleChoiceIndices(answerData);
if (!options.length || !baseIndices.length || !exercise?.lessonId) {
return answerData;
}
const prompt = this._getMultipleChoicePrompt(questionData);
if (!prompt) {
return answerData;
}
const optionIndexMap = new Map();
options.forEach((option, index) => {
const normalizedOption = this._normalizeTextAnswer(option);
if (!normalizedOption) return;
const existing = optionIndexMap.get(normalizedOption) || [];
existing.push(index);
optionIndexMap.set(normalizedOption, existing);
});
const lessonExercises = await VocabGrammarExercise.findAll({
where: {
lessonId: exercise.lessonId,
exerciseTypeId: 2
},
attributes: ['id', 'questionData', 'answerData']
});
const expandedIndices = new Set(baseIndices);
lessonExercises.forEach((candidate) => {
const candidateQuestionData = typeof candidate.questionData === 'string'
? JSON.parse(candidate.questionData)
: candidate.questionData;
const candidatePrompt = this._getMultipleChoicePrompt(candidateQuestionData);
if (candidatePrompt !== prompt) {
return;
}
const candidateAnswerData = typeof candidate.answerData === 'string'
? JSON.parse(candidate.answerData)
: candidate.answerData;
const candidateOptions = Array.isArray(candidateQuestionData?.options) ? candidateQuestionData.options : [];
const candidateIndices = this._extractMultipleChoiceIndices(candidateAnswerData);
candidateIndices.forEach((candidateIndex) => {
const candidateOption = candidateOptions[candidateIndex];
const normalizedOption = this._normalizeTextAnswer(candidateOption);
const matchingIndices = optionIndexMap.get(normalizedOption) || [];
matchingIndices.forEach((matchingIndex) => expandedIndices.add(matchingIndex));
});
});
return {
...answerData,
correctAnswer: Array.from(expandedIndices).sort((a, b) => a - b)
};
}
_checkAnswer(answerData, questionData, userAnswer, exerciseTypeId) {
// Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden
if (!answerData || userAnswer === undefined || userAnswer === null) return false;
// Parse JSON strings
const parsedAnswerData = typeof answerData === 'string' ? JSON.parse(answerData) : answerData;
const parsedQuestionData = typeof questionData === 'string' ? JSON.parse(questionData) : questionData;
// Für Multiple Choice: Prüfe ob userAnswer (Index) mit correctAnswer (Index oder Array von Indizes) übereinstimmt
if (exerciseTypeId === 2) { // multiple_choice
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
let correctIndices = [];
if (parsedAnswerData.correctAnswer !== undefined) {
// Kann ein einzelner Index oder ein Array von Indizes sein
if (Array.isArray(parsedAnswerData.correctAnswer)) {
correctIndices = parsedAnswerData.correctAnswer.map(idx => Number(idx));
} else {
correctIndices = [Number(parsedAnswerData.correctAnswer)];
}
} else if (parsedAnswerData.correct !== undefined) {
// Fallback: Prüfe auch 'correct' Feld
if (Array.isArray(parsedAnswerData.correct)) {
correctIndices = parsedAnswerData.correct.map(idx => Number(idx));
} else {
correctIndices = [Number(parsedAnswerData.correct)];
}
}
if (correctIndices.length === 0) return false;
const options = Array.isArray(parsedQuestionData?.options) ? parsedQuestionData.options : [];
const correctTexts = correctIndices
.map((i) => options[i])
.filter((opt) => opt !== undefined && opt !== null);
const norm = (s) => this._normalizeTextAnswer(s);
// Nach zufälligen Distraktoren: Client sendet gewählten Optionstext statt Index
if (typeof userAnswer === 'string') {
const u = norm(userAnswer);
if (!u) return false;
return correctTexts.some((t) => this._isEquivalentAnswer(userAnswer, t));
}
// 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 inkl. hinterlegter Alternativen
const primaryAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
const primaryAnswersArray = Array.isArray(primaryAnswers) ? primaryAnswers : [primaryAnswers];
const alternativeAnswersArray = Array.isArray(parsedAnswerData.alternatives)
? parsedAnswerData.alternatives
: [];
const correctAnswersArray = [...primaryAnswersArray, ...alternativeAnswersArray];
const normalizedUserAnswer = this._normalizeTextAnswer(userAnswer);
return correctAnswersArray.some(correct => this._normalizeTextAnswer(correct) === normalizedUserAnswer);
}
async getGrammarExerciseProgress(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId);
const numericExerciseIds = exercises
.map((e) => e.id)
.filter((id) => /^\d+$/.test(String(id)));
const progress = numericExerciseIds.length
? await VocabGrammarExerciseProgress.findAll({
where: {
userId: user.id,
exerciseId: { [Op.in]: numericExerciseIds }
}
})
: [];
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
return exercises.map(exercise => ({
...exercise,
progress: progressMap.get(exercise.id) || null
}));
}
async updateGrammarExercise(hashedUserId, exerciseId, { title, instruction, questionData, answerData, explanation, exerciseNumber }) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (exercise.lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update exercises');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (instruction !== undefined) updates.instruction = instruction;
if (questionData !== undefined) updates.questionData = questionData;
if (answerData !== undefined) updates.answerData = answerData;
if (explanation !== undefined) updates.explanation = explanation;
if (exerciseNumber !== undefined) updates.exerciseNumber = Number(exerciseNumber);
await exercise.update(updates);
return exercise.get({ plain: true });
}
async deleteGrammarExercise(hashedUserId, exerciseId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (exercise.lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete exercises');
err.status = 403;
throw err;
}
await exercise.destroy();
return { success: true };
}
/**
* Explizite Zuordnung der Antwortsprache (sprachneutral).
* questionData.answerLanguage: 'target' | 'native'
* oder questionData.answerLanguageId: 1 = target (Lernsprache), 2 = native (Muttersprache)
* Ohne diese Felder: 'unknown' (kein Eintrag in den Distraktor-Pools für diese Frage).
* @param {object} questionData
* @returns {'target'|'native'|'unknown'}
*/
_resolveMcAnswerSide(questionData) {
if (!questionData || typeof questionData !== 'object') return 'unknown';
const raw = questionData.answerLanguage;
if (typeof raw === 'string') {
const s = raw.trim().toLowerCase();
if (s === 'target' || s === 'learning' || s === 'l2') return 'target';
if (s === 'native' || s === 'l1') return 'native';
}
const id = questionData.answerLanguageId;
if (id === 1 || id === '1') return 'target';
if (id === 2 || id === '2') return 'native';
return 'unknown';
}
/**
* Sammelt Vokabeln aus allen Multiple-Choice-Übungen von Lektionen **vor** der angegebenen Lektion
* (gleicher Kurs), getrennt nach Ziel- vs. Muttersprache anhand von answerLanguage / answerLanguageId.
*/
async getVocabDistractorPool(hashedUserId, courseId, beforeLessonId) {
if (!beforeLessonId) {
const err = new Error('beforeLessonId is required');
err.status = 400;
throw err;
}
const user = await this._getUserByHashedId(hashedUserId);
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) },
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const currentLesson = await VocabCourseLesson.findByPk(beforeLessonId);
if (!currentLesson || currentLesson.courseId !== Number(courseId)) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
const priorLessons = await VocabCourseLesson.findAll({
where: {
courseId: Number(courseId),
lessonNumber: { [Op.lt]: currentLesson.lessonNumber },
},
attributes: ['id'],
order: [['lessonNumber', 'ASC']],
});
const lessonIds = priorLessons.map((l) => l.id);
if (lessonIds.length === 0) {
return { target: [], native: [] };
}
const exercises = await VocabGrammarExercise.findAll({
where: {
lessonId: { [Op.in]: lessonIds },
exerciseTypeId: 2,
},
attributes: ['questionData'],
});
const target = new Set();
const native = new Set();
for (const ex of exercises) {
const qd =
typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData;
const opts = qd?.options;
if (!Array.isArray(opts)) continue;
const side = this._resolveMcAnswerSide(qd);
if (side === 'target') {
opts.forEach((o) => target.add(String(o).trim()));
} else if (side === 'native') {
opts.forEach((o) => native.add(String(o).trim()));
}
}
return {
target: [...target],
native: [...native],
};
}
}