All checks were successful
Deploy to production / deploy (push) Successful in 2m59s
- Implemented `getUserVocabCourses` and `getVocabCourseForAdmin` methods in `AdminController` to allow admins to retrieve enrolled vocab courses for users and specific course details, respectively. - Updated `adminRouter` to include new routes for accessing user vocab courses and course details. - Enhanced `AdminService` with methods to list user-enrolled vocab courses and retrieve course information with lessons, ensuring proper access control. - Improved `VocabService` to support the new functionalities, including attaching language names to course data. - Updated UI components in `UsersView` to reflect changes, including error handling and loading states for course retrieval, along with localization updates for new features.
3212 lines
107 KiB
JavaScript
3212 lines
107 KiB
JavaScript
import crypto from 'crypto';
|
|
import User from '../models/community/user.js';
|
|
import VocabCourse from '../models/community/vocab_course.js';
|
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
|
import VocabCourseEnrollment from '../models/community/vocab_course_enrollment.js';
|
|
import VocabCourseProgress from '../models/community/vocab_course_progress.js';
|
|
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
|
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
|
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
|
|
import UserParamType from '../models/type/user_param.js';
|
|
import UserParam from '../models/community/user_param.js';
|
|
import { sequelize } from '../utils/sequelize.js';
|
|
import { notifyUser } from '../utils/socket.js';
|
|
import { Op } from 'sequelize';
|
|
import { BISAYA_PHASE1_DIDACTICS } from '../scripts/bisaya-course-phase1.js';
|
|
|
|
export default class VocabService {
|
|
_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)));
|
|
}
|
|
|
|
_sanitizeShortString(value, maxLength = 400) {
|
|
const text = String(value ?? '').trim();
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
return text.slice(0, maxLength);
|
|
}
|
|
|
|
_sanitizeStringArray(value, { maxItems = 12, maxLength = 400, keepEmpty = false } = {}) {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return value
|
|
.slice(0, maxItems)
|
|
.map((entry) => this._sanitizeShortString(entry, maxLength))
|
|
.filter((entry) => keepEmpty || Boolean(entry));
|
|
}
|
|
|
|
_sanitizeExerciseAnswers(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const sanitized = {};
|
|
Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => {
|
|
if (!/^\d+$/.test(String(exerciseId))) {
|
|
return;
|
|
}
|
|
if (Array.isArray(answer)) {
|
|
sanitized[exerciseId] = this._sanitizeStringArray(answer, {
|
|
maxItems: 12,
|
|
maxLength: 200,
|
|
keepEmpty: true
|
|
});
|
|
return;
|
|
}
|
|
if (typeof answer === 'string') {
|
|
sanitized[exerciseId] = this._sanitizeShortString(answer, 200);
|
|
return;
|
|
}
|
|
if (typeof answer === 'number' && Number.isFinite(answer)) {
|
|
sanitized[exerciseId] = Math.trunc(answer);
|
|
}
|
|
});
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
_sanitizeExerciseResults(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const sanitized = {};
|
|
Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => {
|
|
if (!/^\d+$/.test(String(exerciseId)) || !result || typeof result !== 'object' || Array.isArray(result)) {
|
|
return;
|
|
}
|
|
|
|
sanitized[exerciseId] = {
|
|
correct: Boolean(result.correct),
|
|
correctAnswer: this._sanitizeShortString(result.correctAnswer, 400),
|
|
alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }),
|
|
explanation: this._sanitizeShortString(result.explanation, 1200)
|
|
};
|
|
});
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
_sanitizeVocabTrainerStats(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const sanitized = {};
|
|
Object.entries(value).slice(0, 400).forEach(([key, stats]) => {
|
|
const safeKey = this._sanitizeShortString(key, 200);
|
|
if (!safeKey || !stats || typeof stats !== 'object' || Array.isArray(stats)) {
|
|
return;
|
|
}
|
|
|
|
sanitized[safeKey] = {
|
|
attempts: this._clampInteger(stats.attempts, { max: 5000 }),
|
|
correct: this._clampInteger(stats.correct, { max: 5000 }),
|
|
wrong: this._clampInteger(stats.wrong, { max: 5000 })
|
|
};
|
|
});
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
_sanitizeRepeatQueue(value) {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.slice(0, 100)
|
|
.map((entry) => ({
|
|
key: this._sanitizeShortString(entry?.key, 200),
|
|
dueAfter: this._clampInteger(entry?.dueAfter, { min: 0, max: 50 }),
|
|
stageIndex: this._clampInteger(entry?.stageIndex, { min: 0, max: 10 })
|
|
}))
|
|
.filter((entry) => entry.key);
|
|
}
|
|
|
|
_sanitizeLessonState(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const knownKeys = [
|
|
'version',
|
|
'updatedAt',
|
|
'activeTab',
|
|
'exercisePreparationCompleted',
|
|
'lessonPrepStage',
|
|
'lessonPrepIndex',
|
|
'vocabTrainerActive',
|
|
'vocabTrainerMode',
|
|
'vocabTrainerAutoSwitchedToTyping',
|
|
'vocabTrainerCorrect',
|
|
'vocabTrainerWrong',
|
|
'vocabTrainerTotalAttempts',
|
|
'vocabTrainerCurrentAttempts',
|
|
'vocabTrainerReviewAttempts',
|
|
'vocabTrainerStats',
|
|
'vocabTrainerRepeatQueue',
|
|
'exerciseAnswers',
|
|
'exerciseResults',
|
|
'exerciseRetryPending',
|
|
'exerciseRetryPendingSinceAttempts',
|
|
'reviewStage',
|
|
'reviewNextDueAt',
|
|
'reviewLastReviewedAt'
|
|
];
|
|
const hasKnownState = knownKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
if (!hasKnownState) {
|
|
return {};
|
|
}
|
|
|
|
const activeTab = value.activeTab === 'exercises' ? 'exercises' : 'learn';
|
|
const vocabTrainerMode = value.vocabTrainerMode === 'typing' ? 'typing' : 'multiple_choice';
|
|
|
|
return {
|
|
version: this._clampInteger(value.version, { min: 1, max: 1000, fallback: 1 }),
|
|
updatedAt: this._sanitizeShortString(value.updatedAt || new Date().toISOString(), 64),
|
|
activeTab,
|
|
exercisePreparationCompleted: Boolean(value.exercisePreparationCompleted),
|
|
lessonPrepStage: this._clampInteger(value.lessonPrepStage, { min: 0, max: 2 }),
|
|
lessonPrepIndex: this._clampInteger(value.lessonPrepIndex, { min: 0, max: 500 }),
|
|
vocabTrainerActive: Boolean(value.vocabTrainerActive),
|
|
vocabTrainerMode,
|
|
vocabTrainerAutoSwitchedToTyping: Boolean(value.vocabTrainerAutoSwitchedToTyping),
|
|
vocabTrainerCorrect: this._clampInteger(value.vocabTrainerCorrect, { max: 5000 }),
|
|
vocabTrainerWrong: this._clampInteger(value.vocabTrainerWrong, { max: 5000 }),
|
|
vocabTrainerTotalAttempts: this._clampInteger(value.vocabTrainerTotalAttempts, { max: 10000 }),
|
|
vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }),
|
|
vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }),
|
|
vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats),
|
|
vocabTrainerRepeatQueue: this._sanitizeRepeatQueue(value.vocabTrainerRepeatQueue),
|
|
exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers),
|
|
exerciseResults: this._sanitizeExerciseResults(value.exerciseResults),
|
|
exerciseRetryPending: Boolean(value.exerciseRetryPending),
|
|
exerciseRetryPendingSinceAttempts: this._clampInteger(value.exerciseRetryPendingSinceAttempts, { max: 10000 }),
|
|
reviewStage: this._clampInteger(value.reviewStage, { min: 0, max: 3 }),
|
|
reviewNextDueAt: this._normalizeIsoDate(value.reviewNextDueAt),
|
|
reviewLastReviewedAt: this._normalizeIsoDate(value.reviewLastReviewedAt)
|
|
};
|
|
}
|
|
|
|
_supportsScheduledReview(lessonData = null) {
|
|
const lessonType = String(lessonData?.lessonType || '').toLowerCase();
|
|
const didacticMode = String(lessonData?.didacticMode || '').toLowerCase();
|
|
if (lessonType === 'culture' || lessonType === 'review' || lessonType === 'vocab_review') {
|
|
return false;
|
|
}
|
|
if (didacticMode === 'intensive_review' || didacticMode === 'checkpoint') {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
_removeManagedReviewState(lessonState = {}) {
|
|
const nextState = { ...(lessonState || {}) };
|
|
delete nextState.reviewStage;
|
|
delete nextState.reviewNextDueAt;
|
|
delete nextState.reviewLastReviewedAt;
|
|
return nextState;
|
|
}
|
|
|
|
_applyScheduledReviewState(lessonState = {}, {
|
|
previousCompleted = false,
|
|
nextCompleted = false,
|
|
shouldAdvanceReview = false,
|
|
lessonData = null,
|
|
now = new Date()
|
|
} = {}) {
|
|
const baseState = this._sanitizeLessonState(lessonState);
|
|
if (!this._supportsScheduledReview(lessonData) || !nextCompleted) {
|
|
return this._removeManagedReviewState(baseState);
|
|
}
|
|
|
|
const reviewIntervalsDays = [1, 3, 7];
|
|
const currentStage = this._clampInteger(baseState.reviewStage, { min: 0, max: reviewIntervalsDays.length });
|
|
const dueAtIso = this._normalizeIsoDate(baseState.reviewNextDueAt);
|
|
const dueAt = dueAtIso ? new Date(dueAtIso) : null;
|
|
const reviewLastReviewedAt = this._normalizeIsoDate(baseState.reviewLastReviewedAt);
|
|
const nowIso = this._normalizeIsoDate(now);
|
|
|
|
const nextState = {
|
|
...baseState,
|
|
reviewStage: currentStage,
|
|
reviewNextDueAt: dueAtIso,
|
|
reviewLastReviewedAt
|
|
};
|
|
|
|
if (!previousCompleted && shouldAdvanceReview) {
|
|
nextState.reviewStage = 0;
|
|
nextState.reviewLastReviewedAt = nowIso;
|
|
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[0] * 24 * 60 * 60 * 1000));
|
|
return nextState;
|
|
}
|
|
|
|
if (!dueAtIso && currentStage < reviewIntervalsDays.length) {
|
|
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[currentStage] * 24 * 60 * 60 * 1000));
|
|
}
|
|
|
|
if (!shouldAdvanceReview || !dueAt || Number.isNaN(dueAt.getTime()) || now.getTime() < dueAt.getTime()) {
|
|
return nextState;
|
|
}
|
|
|
|
const nextStage = Math.min(reviewIntervalsDays.length, currentStage + 1);
|
|
nextState.reviewStage = nextStage;
|
|
nextState.reviewLastReviewedAt = nowIso;
|
|
nextState.reviewNextDueAt = nextStage >= reviewIntervalsDays.length
|
|
? ''
|
|
: this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[nextStage] * 24 * 60 * 60 * 1000));
|
|
return nextState;
|
|
}
|
|
|
|
_serializeLessonProgress(progress, lessonData = null) {
|
|
if (!progress) {
|
|
return null;
|
|
}
|
|
|
|
const plainProgress = progress.get ? progress.get({ plain: true }) : { ...progress };
|
|
const targetScore = lessonData?.targetScorePercent || plainProgress.lesson?.targetScorePercent || 80;
|
|
const hasReachedTarget = (plainProgress.score || 0) >= targetScore;
|
|
const lessonState = this._sanitizeLessonState(plainProgress.lessonState);
|
|
const reviewStage = this._clampInteger(lessonState.reviewStage, { min: 0, max: 3 });
|
|
const reviewNextDueAt = this._normalizeIsoDate(lessonState.reviewNextDueAt);
|
|
const reviewDue = Boolean(reviewNextDueAt && reviewStage < 3 && new Date(reviewNextDueAt).getTime() <= Date.now());
|
|
|
|
return {
|
|
...plainProgress,
|
|
lessonState,
|
|
targetScore,
|
|
hasReachedTarget,
|
|
needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget),
|
|
reviewStage,
|
|
reviewNextDueAt,
|
|
reviewDue,
|
|
reviewCompleted: reviewStage >= 3
|
|
};
|
|
}
|
|
|
|
async _getUserByHashedId(hashedUserId) {
|
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
|
if (!user) {
|
|
const err = new Error('User not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
return user;
|
|
}
|
|
|
|
async _attachLanguageNamesToCourseRows(coursesData) {
|
|
if (!coursesData.length) {
|
|
return;
|
|
}
|
|
const languageIds = [...new Set(coursesData.map((c) => c.languageId))];
|
|
if (languageIds.length > 0) {
|
|
const languages = await sequelize.query(
|
|
`SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`,
|
|
{
|
|
replacements: { languageIds },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
if (Array.isArray(languages)) {
|
|
const languageMap = new Map(languages.map((l) => [l.id, l.name]));
|
|
coursesData.forEach((c) => {
|
|
c.languageName = languageMap.get(c.languageId) || null;
|
|
});
|
|
}
|
|
}
|
|
const nativeLanguageIds = [...new Set(coursesData.map((c) => c.nativeLanguageId).filter((id) => id !== null))];
|
|
if (nativeLanguageIds.length > 0) {
|
|
const nativeLanguages = await sequelize.query(
|
|
`SELECT id, name FROM community.vocab_language WHERE id IN (:nativeLanguageIds)`,
|
|
{
|
|
replacements: { nativeLanguageIds },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
if (Array.isArray(nativeLanguages)) {
|
|
const nativeLanguageMap = new Map(nativeLanguages.map((l) => [l.id, l.name]));
|
|
coursesData.forEach((c) => {
|
|
c.nativeLanguageName = c.nativeLanguageId ? nativeLanguageMap.get(c.nativeLanguageId) || null : null;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async _getUserLlmConfig(userId) {
|
|
const [settingsType, apiKeyType] = await Promise.all([
|
|
UserParamType.findOne({ where: { description: 'llm_settings' } }),
|
|
UserParamType.findOne({ where: { description: 'llm_api_key' } })
|
|
]);
|
|
|
|
if (!settingsType || !apiKeyType) {
|
|
return {
|
|
enabled: false,
|
|
baseUrl: '',
|
|
model: 'gpt-4o-mini',
|
|
hasKey: false,
|
|
apiKey: null,
|
|
configured: false
|
|
};
|
|
}
|
|
|
|
const [settingsRow, keyRow] = await Promise.all([
|
|
UserParam.findOne({ where: { userId, paramTypeId: settingsType.id } }),
|
|
UserParam.findOne({ where: { userId, paramTypeId: apiKeyType.id } })
|
|
]);
|
|
|
|
let parsed = {};
|
|
if (settingsRow?.value) {
|
|
try {
|
|
parsed = JSON.parse(settingsRow.value);
|
|
} catch {
|
|
parsed = {};
|
|
}
|
|
}
|
|
|
|
const decryptedKey = keyRow?.value ? String(keyRow.value).trim() : null;
|
|
const hasKey = Boolean(decryptedKey && String(decryptedKey).trim());
|
|
const enabled = parsed.enabled !== false;
|
|
const baseUrl = String(parsed.baseUrl || '').trim();
|
|
|
|
return {
|
|
enabled,
|
|
baseUrl,
|
|
model: String(parsed.model || 'gpt-4o-mini').trim() || 'gpt-4o-mini',
|
|
hasKey,
|
|
apiKey: hasKey ? decryptedKey : null,
|
|
configured: enabled && (hasKey || Boolean(baseUrl))
|
|
};
|
|
}
|
|
|
|
_sanitizeAssistantHistory(history) {
|
|
if (!Array.isArray(history)) {
|
|
return [];
|
|
}
|
|
|
|
return history
|
|
.slice(-8)
|
|
.map((entry) => ({
|
|
role: entry?.role === 'assistant' ? 'assistant' : 'user',
|
|
content: String(entry?.content || '').trim()
|
|
}))
|
|
.filter((entry) => entry.content);
|
|
}
|
|
|
|
_buildLessonAssistantSystemPrompt(lesson, mode = 'practice') {
|
|
const didactics = lesson?.didactics || {};
|
|
const learningGoals = Array.isArray(didactics.learningGoals) ? didactics.learningGoals : [];
|
|
const corePatterns = Array.isArray(didactics.corePatterns) ? didactics.corePatterns : [];
|
|
const speakingPrompts = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts : [];
|
|
const practicalTasks = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks : [];
|
|
|
|
const modeDirectives = {
|
|
explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.',
|
|
practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.',
|
|
correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.'
|
|
};
|
|
|
|
return [
|
|
'Du bist ein didaktischer Sprachassistent innerhalb eines Sprachkurses.',
|
|
'Antworte auf Deutsch, aber verwende die Zielsprache der Lektion aktiv in Beispielen und Mini-Dialogen.',
|
|
modeDirectives[mode] || modeDirectives.practice,
|
|
'Halte Antworten kompakt, praxisnah und auf diese Lektion fokussiert.',
|
|
`Kurs: ${lesson?.course?.title || 'Unbekannter Kurs'}`,
|
|
`Lektion: ${lesson?.title || 'Unbekannte Lektion'}`,
|
|
lesson?.description ? `Beschreibung: ${lesson.description}` : '',
|
|
learningGoals.length ? `Lernziele: ${learningGoals.join(' | ')}` : '',
|
|
corePatterns.length
|
|
? `Kernmuster: ${corePatterns.map((p) => {
|
|
const n = this._normalizeCorePatternEntry(p);
|
|
if (!n) return '';
|
|
return n.gloss ? `${n.target} (${n.gloss})` : n.target;
|
|
}).filter(Boolean).join(' | ')}`
|
|
: '',
|
|
speakingPrompts.length
|
|
? `Sprechaufträge: ${speakingPrompts.map((item) => item.prompt || item.title || '').filter(Boolean).join(' | ')}`
|
|
: '',
|
|
practicalTasks.length
|
|
? `Praxisaufgaben: ${practicalTasks.map((item) => item.text || item.title || '').filter(Boolean).join(' | ')}`
|
|
: '',
|
|
'Wenn der Nutzer eine Formulierung versucht, korrigiere sie präzise und gib eine verbesserte Version.'
|
|
].filter(Boolean).join('\n');
|
|
}
|
|
|
|
_extractAssistantContent(responseData) {
|
|
const rawContent = responseData?.choices?.[0]?.message?.content;
|
|
if (typeof rawContent === 'string') {
|
|
return rawContent.trim();
|
|
}
|
|
if (Array.isArray(rawContent)) {
|
|
return rawContent
|
|
.map((item) => {
|
|
if (typeof item === 'string') return item;
|
|
if (item?.type === 'text') return item.text || '';
|
|
return '';
|
|
})
|
|
.join('\n')
|
|
.trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
_normalizeLexeme(text) {
|
|
return String(text || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
_normalizeTextAnswer(text) {
|
|
const normalized = String(text || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize('NFKC')
|
|
.replace(/[\p{P}\p{S}]+/gu, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return normalized.replace(/\s+/g, '');
|
|
}
|
|
|
|
_parseExercisePayload(value) {
|
|
if (!value) return {};
|
|
if (typeof value === 'string') {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
if (typeof value === 'object') {
|
|
return value;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
_extractTrainerVocabsFromExercises(exercises = []) {
|
|
const vocabMap = new Map();
|
|
|
|
exercises.forEach((exercise) => {
|
|
try {
|
|
const qData = this._parseExercisePayload(exercise.questionData);
|
|
const aData = this._parseExercisePayload(exercise.answerData);
|
|
const exerciseType = exercise.exerciseType?.name || qData.type || '';
|
|
|
|
if (exerciseType === 'multiple_choice') {
|
|
const options = Array.isArray(qData.options) ? qData.options : [];
|
|
const correctAnswer = Array.isArray(aData.correctAnswer)
|
|
? options[aData.correctAnswer[0]]
|
|
: options[aData.correctAnswer ?? aData.correct ?? 0];
|
|
const question = String(qData.question || qData.text || '');
|
|
|
|
let match = question.match(/Wie sagt man ['"]([^'"]+)['"]/i);
|
|
if (match && match[1] && correctAnswer && match[1].trim() !== String(correctAnswer).trim()) {
|
|
vocabMap.set(`${match[1]}-${correctAnswer}`, {
|
|
learning: match[1],
|
|
reference: String(correctAnswer)
|
|
});
|
|
return;
|
|
}
|
|
|
|
match = question.match(/Was bedeutet ['"]([^'"]+)['"]/i);
|
|
if (match && match[1] && correctAnswer && match[1].trim() !== String(correctAnswer).trim()) {
|
|
vocabMap.set(`${correctAnswer}-${match[1]}`, {
|
|
learning: String(correctAnswer),
|
|
reference: match[1]
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (exerciseType === 'gap_fill') {
|
|
const answers = Array.isArray(aData.answers)
|
|
? aData.answers
|
|
: (aData.correct ? (Array.isArray(aData.correct) ? aData.correct : [aData.correct]) : []);
|
|
const text = String(qData.text || '');
|
|
const nativeWords = Array.from(text.matchAll(/\(([^)]+)\)/g), (m) => String(m[1] || '').trim());
|
|
|
|
if (!answers.length || !nativeWords.length) {
|
|
return;
|
|
}
|
|
|
|
answers.forEach((answer, index) => {
|
|
const nativeWord = nativeWords[index];
|
|
const normalizedAnswer = String(answer || '').trim();
|
|
if (!nativeWord || !normalizedAnswer || nativeWord === normalizedAnswer) {
|
|
return;
|
|
}
|
|
vocabMap.set(`${nativeWord}-${normalizedAnswer}`, {
|
|
learning: nativeWord,
|
|
reference: normalizedAnswer
|
|
});
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn('Fehler beim Extrahieren von Trainer-Vokabeln:', error);
|
|
}
|
|
});
|
|
|
|
return Array.from(vocabMap.values());
|
|
}
|
|
|
|
_extractTrainerVocabsFromLessonDidactics(lesson) {
|
|
const vocabMap = new Map();
|
|
const speakingPrompts = Array.isArray(lesson?.speakingPrompts) ? lesson.speakingPrompts : [];
|
|
const practicalTasks = Array.isArray(lesson?.practicalTasks) ? lesson.practicalTasks : [];
|
|
const corePatterns = Array.isArray(lesson?.corePatterns) ? lesson.corePatterns : [];
|
|
|
|
speakingPrompts.forEach((prompt, index) => {
|
|
const learning = String(prompt?.prompt || prompt?.title || '').trim();
|
|
const refEntry = corePatterns[index] ?? corePatterns[0];
|
|
const reference = String(prompt?.cue || this._corePatternTarget(refEntry) || '').trim();
|
|
if (!learning || !reference || learning === reference) return;
|
|
vocabMap.set(`${learning}-${reference}`, { learning, reference });
|
|
});
|
|
|
|
practicalTasks.forEach((task, index) => {
|
|
const learning = String(task?.text || task?.title || '').trim();
|
|
const refEntry = corePatterns[index] ?? corePatterns[0];
|
|
const reference = String(this._corePatternTarget(refEntry) || '').trim();
|
|
if (!learning || !reference || learning === reference) return;
|
|
vocabMap.set(`${learning}-${reference}`, { learning, reference });
|
|
});
|
|
|
|
return Array.from(vocabMap.values());
|
|
}
|
|
|
|
_normalizeStringList(value) {
|
|
if (!value) return [];
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.map((entry) => String(entry || '').trim())
|
|
.filter(Boolean);
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value
|
|
.split(/\r?\n|;/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Kernmuster: Zielsprachen-Phrase + optionale Glossierung (z. B. Deutsch).
|
|
* Unterstützt Legacy-Strings, "Phrase|Gloss" und Objekte { target, gloss } / { ceb, de }.
|
|
*/
|
|
_normalizeCorePatternEntry(entry) {
|
|
if (entry === null || entry === undefined || entry === '') {
|
|
return null;
|
|
}
|
|
if (typeof entry === 'object' && !Array.isArray(entry)) {
|
|
const target = String(entry.target ?? entry.ceb ?? entry.phrase ?? '').trim();
|
|
const gloss = String(entry.gloss ?? entry.de ?? entry.translation ?? '').trim();
|
|
if (!target) return null;
|
|
return { target, gloss };
|
|
}
|
|
const s = String(entry).trim();
|
|
if (!s) return null;
|
|
const pipe = s.indexOf('|');
|
|
if (pipe !== -1) {
|
|
const target = s.slice(0, pipe).trim();
|
|
const gloss = s.slice(pipe + 1).trim();
|
|
if (!target) return null;
|
|
return { target, gloss };
|
|
}
|
|
return { target: s, gloss: '' };
|
|
}
|
|
|
|
_normalizeCorePatternList(value) {
|
|
if (!value) return [];
|
|
const raw = Array.isArray(value)
|
|
? value
|
|
: (typeof value === 'string'
|
|
? value.split(/\r?\n|;/).map((entry) => entry.trim()).filter(Boolean)
|
|
: []);
|
|
return raw
|
|
.map((entry) => this._normalizeCorePatternEntry(entry))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
_corePatternTarget(entry) {
|
|
const n = this._normalizeCorePatternEntry(entry);
|
|
return n ? n.target : '';
|
|
}
|
|
|
|
_enrichCorePatternsWithGloss(corePatterns = [], extractedVocabs = []) {
|
|
const glossByReference = new Map();
|
|
|
|
extractedVocabs.forEach((item) => {
|
|
const reference = this._normalizeLexeme(item?.reference);
|
|
const learning = String(item?.learning || '').trim();
|
|
if (!reference || !learning) {
|
|
return;
|
|
}
|
|
if (!glossByReference.has(reference)) {
|
|
glossByReference.set(reference, learning);
|
|
}
|
|
});
|
|
|
|
return corePatterns
|
|
.map((entry) => this._normalizeCorePatternEntry(entry))
|
|
.filter(Boolean)
|
|
.map((entry) => {
|
|
if (entry.gloss) {
|
|
return entry;
|
|
}
|
|
const gloss = glossByReference.get(this._normalizeLexeme(entry.target)) || '';
|
|
return gloss ? { ...entry, gloss } : entry;
|
|
});
|
|
}
|
|
|
|
_mergeCorePatternGlosses(primaryPatterns = [], fallbackPatterns = []) {
|
|
const fallbackByTarget = new Map(
|
|
fallbackPatterns
|
|
.map((entry) => this._normalizeCorePatternEntry(entry))
|
|
.filter(Boolean)
|
|
.map((entry) => [this._normalizeLexeme(entry.target), entry.gloss || ''])
|
|
);
|
|
|
|
return primaryPatterns.map((entry) => {
|
|
const normalized = this._normalizeCorePatternEntry(entry);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
if (normalized.gloss) {
|
|
return normalized;
|
|
}
|
|
const gloss = fallbackByTarget.get(this._normalizeLexeme(normalized.target)) || '';
|
|
return gloss ? { ...normalized, gloss } : normalized;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
_normalizeStructuredList(value, keys = ['title', 'text']) {
|
|
if (!value) return [];
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.map((entry) => {
|
|
if (typeof entry === 'string') {
|
|
return { title: '', text: entry.trim() };
|
|
}
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
const normalized = {};
|
|
keys.forEach((key) => {
|
|
if (entry[key] !== undefined && entry[key] !== null) {
|
|
normalized[key] = String(entry[key]).trim();
|
|
}
|
|
});
|
|
return Object.keys(normalized).length > 0 ? normalized : null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
_normalizeOptionalInteger(value) {
|
|
if (value === undefined || value === null || value === '') {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
_normalizeOptionalString(value) {
|
|
if (value === undefined || value === null) {
|
|
return null;
|
|
}
|
|
const trimmed = String(value).trim();
|
|
return trimmed || null;
|
|
}
|
|
|
|
_inferLessonPhaseLabel(plainLesson) {
|
|
if (plainLesson.phaseLabel) {
|
|
return plainLesson.phaseLabel;
|
|
}
|
|
const weekNumber = Number(plainLesson.weekNumber) || 0;
|
|
if (weekNumber > 0 && weekNumber <= 2) {
|
|
return 'quickstart';
|
|
}
|
|
if (weekNumber === 3) {
|
|
return 'daily_life';
|
|
}
|
|
if (weekNumber >= 4) {
|
|
return 'stabilization';
|
|
}
|
|
return 'quickstart';
|
|
}
|
|
|
|
_inferLessonDidacticMode(plainLesson) {
|
|
const lessonType = String(plainLesson.lessonType || '').toLowerCase();
|
|
const title = String(plainLesson.title || '').toLowerCase();
|
|
const storedMode = String(plainLesson.didacticMode || '').trim();
|
|
|
|
const isContrastTraining = lessonType === 'grammar' && [
|
|
'kontrast',
|
|
'fehlertraining',
|
|
' / ',
|
|
'nicht / kein',
|
|
'der / die / das',
|
|
'wo / wohin',
|
|
'du / sie',
|
|
'haben / sein',
|
|
'ich bin / ich habe',
|
|
'ich bin / ich heiße / ich komme'
|
|
].some((marker) => title.includes(marker));
|
|
|
|
if (storedMode && storedMode !== 'pattern_drill') {
|
|
return storedMode;
|
|
}
|
|
if (isContrastTraining) {
|
|
return 'contrast_training';
|
|
}
|
|
if (storedMode) {
|
|
return storedMode;
|
|
}
|
|
if (title.includes('abschluss') || title.includes('prüfung') || title.includes('test')) {
|
|
return 'checkpoint';
|
|
}
|
|
if (plainLesson.isIntensiveReview || lessonType === 'review' || lessonType === 'vocab_review' || title.includes('wiederholung')) {
|
|
return 'intensive_review';
|
|
}
|
|
if (lessonType === 'grammar') {
|
|
return 'pattern_drill';
|
|
}
|
|
if (lessonType === 'conversation' || lessonType === 'dialogue' || lessonType === 'phrases' || lessonType === 'survival') {
|
|
return 'guided_dialogue';
|
|
}
|
|
if (lessonType === 'culture') {
|
|
return 'real_life_scenario';
|
|
}
|
|
return 'core_input';
|
|
}
|
|
|
|
_inferLessonDifficultyWeight(plainLesson, didacticMode) {
|
|
if (plainLesson.difficultyWeight != null) {
|
|
return plainLesson.difficultyWeight;
|
|
}
|
|
switch (didacticMode) {
|
|
case 'contrast_training':
|
|
case 'pattern_drill':
|
|
return 3;
|
|
case 'guided_dialogue':
|
|
case 'real_life_scenario':
|
|
return 2;
|
|
case 'intensive_review':
|
|
case 'checkpoint':
|
|
return 2;
|
|
default:
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
_inferLessonNewUnitTarget(plainLesson, didacticMode) {
|
|
if (plainLesson.newUnitTarget != null) {
|
|
return plainLesson.newUnitTarget;
|
|
}
|
|
switch (didacticMode) {
|
|
case 'contrast_training':
|
|
return 3;
|
|
case 'core_input':
|
|
return 8;
|
|
case 'guided_dialogue':
|
|
return 5;
|
|
case 'pattern_drill':
|
|
return 4;
|
|
case 'real_life_scenario':
|
|
return 3;
|
|
case 'checkpoint':
|
|
return 2;
|
|
case 'intensive_review':
|
|
return 1;
|
|
default:
|
|
return 4;
|
|
}
|
|
}
|
|
|
|
_inferLessonReviewWeight(plainLesson, didacticMode) {
|
|
if (plainLesson.reviewWeight != null) {
|
|
return plainLesson.reviewWeight;
|
|
}
|
|
switch (didacticMode) {
|
|
case 'intensive_review':
|
|
return 90;
|
|
case 'checkpoint':
|
|
return 70;
|
|
case 'contrast_training':
|
|
return 70;
|
|
case 'pattern_drill':
|
|
return 55;
|
|
case 'real_life_scenario':
|
|
return 45;
|
|
case 'guided_dialogue':
|
|
return 40;
|
|
default:
|
|
return 30;
|
|
}
|
|
}
|
|
|
|
_inferLessonBlockNumber(plainLesson) {
|
|
if (plainLesson.blockNumber != null) {
|
|
return plainLesson.blockNumber;
|
|
}
|
|
const weekNumber = Number(plainLesson.weekNumber) || 1;
|
|
return Math.max(1, Math.ceil(weekNumber / 2));
|
|
}
|
|
|
|
_buildLessonPedagogy(plainLesson) {
|
|
const didacticMode = this._inferLessonDidacticMode(plainLesson);
|
|
const phaseLabel = this._inferLessonPhaseLabel(plainLesson);
|
|
const isIntensiveReview = plainLesson.isIntensiveReview != null
|
|
? Boolean(plainLesson.isIntensiveReview)
|
|
: didacticMode === 'intensive_review';
|
|
|
|
return {
|
|
didacticMode,
|
|
phaseLabel,
|
|
blockNumber: this._inferLessonBlockNumber(plainLesson),
|
|
difficultyWeight: this._inferLessonDifficultyWeight(plainLesson, didacticMode),
|
|
newUnitTarget: this._inferLessonNewUnitTarget(plainLesson, didacticMode),
|
|
reviewWeight: this._inferLessonReviewWeight(plainLesson, didacticMode),
|
|
isIntensiveReview
|
|
};
|
|
}
|
|
|
|
_buildLessonDidactics(plainLesson) {
|
|
const grammarExercises = Array.isArray(plainLesson.grammarExercises) ? plainLesson.grammarExercises : [];
|
|
const grammarExplanations = [];
|
|
const patterns = [];
|
|
const speakingPrompts = [];
|
|
|
|
grammarExercises.forEach((exercise) => {
|
|
const questionData = typeof exercise.questionData === 'string'
|
|
? JSON.parse(exercise.questionData)
|
|
: (exercise.questionData || {});
|
|
|
|
if (exercise.explanation) {
|
|
grammarExplanations.push({
|
|
title: exercise.title || '',
|
|
text: exercise.explanation
|
|
});
|
|
}
|
|
|
|
const patternCandidates = [
|
|
questionData.pattern,
|
|
questionData.exampleSentence,
|
|
questionData.modelAnswer,
|
|
questionData.promptSentence
|
|
].filter(Boolean);
|
|
|
|
patternCandidates.forEach((candidate) => {
|
|
patterns.push(String(candidate).trim());
|
|
});
|
|
|
|
if (questionData.type === 'reading_aloud' || questionData.type === 'speaking_from_memory') {
|
|
speakingPrompts.push({
|
|
title: exercise.title || '',
|
|
prompt: questionData.question || questionData.text || '',
|
|
cue: questionData.expectedText || '',
|
|
keywords: Array.isArray(questionData.keywords) ? questionData.keywords : []
|
|
});
|
|
}
|
|
});
|
|
|
|
const uniqueGrammarExplanations = grammarExplanations.filter((item, index, list) => {
|
|
const signature = `${item.title}::${item.text}`;
|
|
return list.findIndex((entry) => `${entry.title}::${entry.text}` === signature) === index;
|
|
});
|
|
|
|
const uniquePatterns = [...new Set(patterns.map((item) => String(item || '').trim()).filter(Boolean))];
|
|
|
|
const learningGoals = this._normalizeStringList(plainLesson.learningGoals);
|
|
const extractedTrainerVocabs = this._extractTrainerVocabsFromExercises(grammarExercises);
|
|
const phase1FallbackCorePatterns = BISAYA_PHASE1_DIDACTICS[plainLesson.title]?.corePatterns || [];
|
|
const corePatterns = this._mergeCorePatternGlosses(
|
|
this._enrichCorePatternsWithGloss(
|
|
this._normalizeCorePatternList(plainLesson.corePatterns),
|
|
extractedTrainerVocabs
|
|
),
|
|
phase1FallbackCorePatterns
|
|
);
|
|
const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']);
|
|
const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']);
|
|
const practicalTasks = this._normalizeStructuredList(plainLesson.practicalTasks, ['title', 'text']);
|
|
|
|
return {
|
|
learningGoals: learningGoals.length > 0
|
|
? learningGoals
|
|
: [
|
|
'Die Schlüsselausdrücke der Lektion verstehen und wiedererkennen.',
|
|
'Ein bis zwei Satzmuster aktiv anwenden.',
|
|
'Kurze Sätze oder Mini-Dialoge zum Thema selbst bilden.'
|
|
],
|
|
corePatterns: corePatterns.length > 0
|
|
? corePatterns
|
|
: this._mergeCorePatternGlosses(
|
|
this._enrichCorePatternsWithGloss(
|
|
uniquePatterns.slice(0, 5).map((s) => ({ target: String(s || '').trim(), gloss: '' })).filter((p) => p.target),
|
|
extractedTrainerVocabs
|
|
),
|
|
phase1FallbackCorePatterns
|
|
),
|
|
grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4),
|
|
speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4),
|
|
practicalTasks: practicalTasks.length > 0
|
|
? practicalTasks
|
|
: [
|
|
{
|
|
title: 'Mini-Anwendung',
|
|
text: 'Formuliere zwei bis drei eigene Sätze oder einen kurzen Dialog mit dem Muster dieser Lektion.'
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
async _getLanguageAccess(userId, languageId) {
|
|
const id = Number.parseInt(languageId, 10);
|
|
if (!Number.isFinite(id)) {
|
|
const err = new Error('Invalid language id');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [row] = await sequelize.query(
|
|
`
|
|
SELECT
|
|
l.id,
|
|
(l.owner_user_id = :userId) AS "isOwner"
|
|
FROM community.vocab_language l
|
|
WHERE l.id = :languageId
|
|
AND (
|
|
l.owner_user_id = :userId
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM community.vocab_language_subscription s
|
|
WHERE s.user_id = :userId AND s.language_id = l.id
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
{
|
|
replacements: { userId, languageId: id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
if (!row) {
|
|
const err = new Error('Language not found or no access');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
async _getChapterAccess(userId, chapterId) {
|
|
const id = Number.parseInt(chapterId, 10);
|
|
if (!Number.isFinite(id)) {
|
|
const err = new Error('Invalid chapter id');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [row] = await sequelize.query(
|
|
`
|
|
SELECT
|
|
c.id,
|
|
c.language_id AS "languageId",
|
|
c.title,
|
|
(l.owner_user_id = :userId) AS "isOwner"
|
|
FROM community.vocab_chapter c
|
|
JOIN community.vocab_language l ON l.id = c.language_id
|
|
WHERE c.id = :chapterId
|
|
AND (
|
|
l.owner_user_id = :userId
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM community.vocab_language_subscription s
|
|
WHERE s.user_id = :userId AND s.language_id = l.id
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
{
|
|
replacements: { userId, chapterId: id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
if (!row) {
|
|
const err = new Error('Chapter not found or no access');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
async listLanguages(hashedUserId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
l.id,
|
|
l.name,
|
|
l.share_code AS "shareCode",
|
|
TRUE AS "isOwner"
|
|
FROM community.vocab_language l
|
|
WHERE l.owner_user_id = :userId
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
l.id,
|
|
l.name,
|
|
NULL::text AS "shareCode",
|
|
FALSE AS "isOwner"
|
|
FROM community.vocab_language_subscription s
|
|
JOIN community.vocab_language l ON l.id = s.language_id
|
|
WHERE s.user_id = :userId
|
|
|
|
ORDER BY name ASC
|
|
`,
|
|
{
|
|
replacements: { userId: user.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { languages: rows };
|
|
}
|
|
|
|
async listAllLanguages() {
|
|
// Gibt alle verfügbaren Sprachen zurück (für Kursliste)
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
id,
|
|
name
|
|
FROM community.vocab_language
|
|
ORDER BY name ASC
|
|
`,
|
|
{
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { languages: rows };
|
|
}
|
|
|
|
async listLanguagesForMenu(userId) {
|
|
// userId ist die numerische community.user.id
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT l.id, l.name
|
|
FROM community.vocab_language l
|
|
WHERE l.owner_user_id = :userId
|
|
UNION
|
|
SELECT l.id, l.name
|
|
FROM community.vocab_language_subscription s
|
|
JOIN community.vocab_language l ON l.id = s.language_id
|
|
WHERE s.user_id = :userId
|
|
ORDER BY name ASC
|
|
`,
|
|
{
|
|
replacements: { userId },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
async createLanguage(hashedUserId, { name }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const cleanName = typeof name === 'string' ? name.trim() : '';
|
|
if (!cleanName || cleanName.length < 2 || cleanName.length > 60) {
|
|
const err = new Error('Invalid language name');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
// 16 hex chars => ausreichend kurz, gut teilbar
|
|
const shareCode = crypto.randomBytes(8).toString('hex');
|
|
|
|
const [created] = await sequelize.query(
|
|
`
|
|
INSERT INTO community.vocab_language (owner_user_id, name, share_code)
|
|
VALUES (:ownerUserId, :name, :shareCode)
|
|
RETURNING id, name, share_code AS "shareCode"
|
|
`,
|
|
{
|
|
replacements: { ownerUserId: user.id, name: cleanName, shareCode },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
// Menü dynamisch nachladen (bei allen offenen Tabs/Clients)
|
|
try {
|
|
notifyUser(user.hashedId, 'reloadmenu', {});
|
|
} catch (_) {}
|
|
|
|
return created;
|
|
}
|
|
|
|
async subscribeByShareCode(hashedUserId, { shareCode }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
|
|
if (!code || code.length < 6 || code.length > 128) {
|
|
const err = new Error('Invalid share code');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [lang] = await sequelize.query(
|
|
`
|
|
SELECT id, owner_user_id AS "ownerUserId", name
|
|
FROM community.vocab_language
|
|
WHERE share_code = :shareCode
|
|
LIMIT 1
|
|
`,
|
|
{
|
|
replacements: { shareCode: code },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
if (!lang) {
|
|
const err = new Error('Language not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Owner braucht kein Abo
|
|
if (lang.ownerUserId === user.id) {
|
|
return { subscribed: false, message: 'Already owner', languageId: lang.id };
|
|
}
|
|
|
|
await sequelize.query(
|
|
`
|
|
INSERT INTO community.vocab_language_subscription (user_id, language_id)
|
|
VALUES (:userId, :languageId)
|
|
ON CONFLICT (user_id, language_id) DO NOTHING
|
|
`,
|
|
{
|
|
replacements: { userId: user.id, languageId: lang.id },
|
|
type: sequelize.QueryTypes.INSERT,
|
|
}
|
|
);
|
|
|
|
try {
|
|
notifyUser(user.hashedId, 'reloadmenu', {});
|
|
} catch (_) {}
|
|
|
|
return { subscribed: true, languageId: lang.id, name: lang.name };
|
|
}
|
|
|
|
async getLanguage(hashedUserId, languageId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const id = Number.parseInt(languageId, 10);
|
|
if (!Number.isFinite(id)) {
|
|
const err = new Error('Invalid language id');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [row] = await sequelize.query(
|
|
`
|
|
SELECT
|
|
l.id,
|
|
l.name,
|
|
CASE WHEN l.owner_user_id = :userId THEN l.share_code ELSE NULL END AS "shareCode",
|
|
(l.owner_user_id = :userId) AS "isOwner"
|
|
FROM community.vocab_language l
|
|
WHERE l.id = :languageId
|
|
AND (
|
|
l.owner_user_id = :userId
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM community.vocab_language_subscription s
|
|
WHERE s.user_id = :userId AND s.language_id = l.id
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
{
|
|
replacements: { userId: user.id, languageId: id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
if (!row) {
|
|
const err = new Error('Language not found or no access');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
async listChapters(hashedUserId, languageId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const access = await this._getLanguageAccess(user.id, languageId);
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
c.id,
|
|
c.title,
|
|
c.created_at AS "createdAt",
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM community.vocab_chapter_lexeme cl
|
|
WHERE cl.chapter_id = c.id
|
|
)::int AS "vocabCount"
|
|
FROM community.vocab_chapter c
|
|
WHERE c.language_id = :languageId
|
|
ORDER BY c.title ASC
|
|
`,
|
|
{
|
|
replacements: { languageId: access.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { chapters: rows, isOwner: access.isOwner };
|
|
}
|
|
|
|
async createChapter(hashedUserId, languageId, { title }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const access = await this._getLanguageAccess(user.id, languageId);
|
|
if (!access.isOwner) {
|
|
const err = new Error('Only owner can create chapters');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const cleanTitle = typeof title === 'string' ? title.trim() : '';
|
|
if (!cleanTitle || cleanTitle.length < 2 || cleanTitle.length > 80) {
|
|
const err = new Error('Invalid chapter title');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [created] = await sequelize.query(
|
|
`
|
|
INSERT INTO community.vocab_chapter (language_id, title, created_by_user_id)
|
|
VALUES (:languageId, :title, :userId)
|
|
RETURNING id, title, created_at AS "createdAt"
|
|
`,
|
|
{
|
|
replacements: { languageId: access.id, title: cleanTitle, userId: user.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return created;
|
|
}
|
|
|
|
async getChapter(hashedUserId, chapterId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const ch = await this._getChapterAccess(user.id, chapterId);
|
|
return { id: ch.id, languageId: ch.languageId, title: ch.title, isOwner: ch.isOwner };
|
|
}
|
|
|
|
async listChapterVocabs(hashedUserId, chapterId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const ch = await this._getChapterAccess(user.id, chapterId);
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
cl.id,
|
|
l1.text AS "learning",
|
|
l2.text AS "reference",
|
|
cl.created_at AS "createdAt"
|
|
FROM community.vocab_chapter_lexeme cl
|
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
|
WHERE cl.chapter_id = :chapterId
|
|
ORDER BY l1.text ASC, l2.text ASC
|
|
`,
|
|
{
|
|
replacements: { chapterId: ch.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { chapter: { id: ch.id, title: ch.title, languageId: ch.languageId, isOwner: ch.isOwner }, vocabs: rows };
|
|
}
|
|
|
|
async listLanguageVocabs(hashedUserId, languageId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const access = await this._getLanguageAccess(user.id, languageId);
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
cl.id,
|
|
c.id AS "chapterId",
|
|
c.title AS "chapterTitle",
|
|
l1.text AS "learning",
|
|
l2.text AS "reference",
|
|
cl.created_at AS "createdAt"
|
|
FROM community.vocab_chapter_lexeme cl
|
|
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
|
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
|
WHERE c.language_id = :languageId
|
|
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
|
|
`,
|
|
{
|
|
replacements: { languageId: access.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
|
|
}
|
|
|
|
async getLessonVocabPool(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [
|
|
{
|
|
model: VocabCourse,
|
|
as: 'course'
|
|
},
|
|
{
|
|
model: VocabGrammarExercise,
|
|
as: 'grammarExercises',
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
}
|
|
],
|
|
required: false
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const progress = await VocabCourseProgress.findOne({
|
|
where: {
|
|
userId: user.id,
|
|
lessonId: lesson.id
|
|
}
|
|
});
|
|
|
|
if (lesson.course.ownerUserId !== user.id && !progress?.completed) {
|
|
const err = new Error('Lesson must be completed first');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const extractedFromExercises = this._extractTrainerVocabsFromExercises(
|
|
(lesson.grammarExercises || []).map((exercise) => exercise.get({ plain: true }))
|
|
);
|
|
const vocabs = extractedFromExercises.length > 0
|
|
? extractedFromExercises
|
|
: this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }));
|
|
|
|
return {
|
|
lesson: {
|
|
id: lesson.id,
|
|
title: lesson.title,
|
|
courseId: lesson.courseId,
|
|
courseTitle: lesson.course.title
|
|
},
|
|
vocabs
|
|
};
|
|
}
|
|
|
|
async getCompletedLessonVocabPool(hashedUserId, courseId, untilLessonId = null) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (course.ownerUserId !== user.id && !course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
let maxLessonNumber = null;
|
|
if (untilLessonId) {
|
|
const untilLesson = await VocabCourseLesson.findOne({
|
|
where: {
|
|
id: untilLessonId,
|
|
courseId: course.id
|
|
},
|
|
attributes: ['lessonNumber']
|
|
});
|
|
|
|
if (!untilLesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
maxLessonNumber = untilLesson.lessonNumber;
|
|
}
|
|
|
|
const completedProgress = await VocabCourseProgress.findAll({
|
|
where: {
|
|
userId: user.id,
|
|
courseId: course.id,
|
|
completed: true
|
|
},
|
|
attributes: ['lessonId']
|
|
});
|
|
|
|
const completedLessonIds = completedProgress.map((entry) => entry.lessonId);
|
|
if (completedLessonIds.length === 0) {
|
|
return { courseId: course.id, vocabs: [] };
|
|
}
|
|
|
|
const lessonWhere = {
|
|
id: {
|
|
[Op.in]: completedLessonIds
|
|
},
|
|
courseId: course.id
|
|
};
|
|
|
|
if (maxLessonNumber != null) {
|
|
lessonWhere.lessonNumber = {
|
|
[Op.lte]: maxLessonNumber
|
|
};
|
|
}
|
|
|
|
const lessons = await VocabCourseLesson.findAll({
|
|
where: lessonWhere,
|
|
attributes: ['id', 'speakingPrompts', 'practicalTasks', 'corePatterns'],
|
|
order: [['lessonNumber', 'ASC']]
|
|
});
|
|
|
|
const lessonIds = lessons.map((lesson) => lesson.id);
|
|
if (!lessonIds.length) {
|
|
return { courseId: course.id, vocabs: [] };
|
|
}
|
|
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: {
|
|
lessonId: {
|
|
[Op.in]: lessonIds
|
|
}
|
|
},
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
}
|
|
],
|
|
order: [['lessonId', 'ASC'], ['exerciseNumber', 'ASC']]
|
|
});
|
|
|
|
const extractedFromExercises = this._extractTrainerVocabsFromExercises(exercises.map((exercise) => exercise.get({ plain: true })));
|
|
const fallbackVocabs = lessons.flatMap((lesson) =>
|
|
this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }))
|
|
);
|
|
const mergedVocabs = new Map();
|
|
[...extractedFromExercises, ...fallbackVocabs].forEach((entry) => {
|
|
if (!entry?.learning || !entry?.reference) return;
|
|
mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry);
|
|
});
|
|
|
|
return {
|
|
courseId: course.id,
|
|
vocabs: Array.from(mergedVocabs.values())
|
|
};
|
|
}
|
|
|
|
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const access = await this._getLanguageAccess(user.id, languageId);
|
|
|
|
const query = typeof q === 'string' ? q.trim() : '';
|
|
// Abwärtskompatibel: falls alte Parameter genutzt werden, zusammenfassen
|
|
const learningTerm = typeof learning === 'string' ? learning.trim() : '';
|
|
const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : '';
|
|
const effective = query || learningTerm || motherTerm;
|
|
|
|
if (!effective) {
|
|
const err = new Error('Missing search term');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const like = `%${effective}%`;
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
cl.id,
|
|
c.id AS "chapterId",
|
|
c.title AS "chapterTitle",
|
|
l1.text AS "learning",
|
|
l2.text AS "motherTongue"
|
|
FROM community.vocab_chapter_lexeme cl
|
|
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
|
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
|
WHERE c.language_id = :languageId
|
|
AND (l1.text ILIKE :like OR l2.text ILIKE :like)
|
|
ORDER BY l2.text ASC, l1.text ASC, c.title ASC
|
|
LIMIT 200
|
|
`,
|
|
{
|
|
replacements: {
|
|
languageId: access.id,
|
|
like,
|
|
},
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { languageId: access.id, results: rows };
|
|
}
|
|
|
|
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)
|
|
}));
|
|
|
|
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 };
|
|
}
|
|
|
|
async getLesson(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [
|
|
{
|
|
model: VocabCourse,
|
|
as: 'course'
|
|
},
|
|
{
|
|
model: VocabGrammarExercise,
|
|
as: 'grammarExercises',
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
}
|
|
],
|
|
required: false,
|
|
separate: true,
|
|
order: [['exerciseNumber', 'ASC']]
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const progress = await VocabCourseProgress.findOne({
|
|
where: {
|
|
userId: user.id,
|
|
lessonId: lesson.id
|
|
}
|
|
});
|
|
|
|
const plainLesson = lesson.get({ plain: true });
|
|
|
|
// Lade Vokabeln aus vorherigen Lektionen (für Wiederholung UND für gemischten Vokabeltrainer)
|
|
if (plainLesson.lessonNumber > 1) {
|
|
plainLesson.previousLessonExercises = await this._getReviewVocabExercises(plainLesson.courseId, plainLesson.lessonNumber);
|
|
}
|
|
// Bei Wiederholungslektionen: Auch Lektions-Liste für Anzeige
|
|
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') {
|
|
plainLesson.reviewLessons = await this._getReviewLessons(plainLesson.courseId, plainLesson.lessonNumber);
|
|
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
|
|
}
|
|
|
|
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
|
|
plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson);
|
|
plainLesson.progress = this._serializeLessonProgress(progress, plainLesson);
|
|
return plainLesson;
|
|
}
|
|
|
|
async sendLessonAssistantMessage(hashedUserId, lessonId, payload = {}) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await this.getLesson(hashedUserId, lessonId);
|
|
const config = await this._getUserLlmConfig(user.id);
|
|
|
|
if (!config.enabled) {
|
|
const err = new Error('Der Sprachassistent ist in deinen Einstellungen derzeit deaktiviert.');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
if (!config.configured) {
|
|
const err = new Error('Der Sprachassistent ist noch nicht eingerichtet. Bitte hinterlege zuerst Modell und API-Zugang in den Einstellungen.');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const message = String(payload?.message || '').trim();
|
|
if (!message) {
|
|
const err = new Error('Bitte gib eine Nachricht für den Sprachassistenten ein.');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const mode = ['explain', 'practice', 'correct'].includes(payload?.mode) ? payload.mode : 'practice';
|
|
const history = this._sanitizeAssistantHistory(payload?.history);
|
|
const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
|
|
const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
|
|
|
|
const headers = {
|
|
'Content-Type': 'application/json'
|
|
};
|
|
if (config.apiKey) {
|
|
headers.Authorization = `Bearer ${config.apiKey}`;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers,
|
|
signal: controller.signal,
|
|
body: JSON.stringify({
|
|
model: config.model,
|
|
temperature: 0.7,
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content: this._buildLessonAssistantSystemPrompt(lesson, mode)
|
|
},
|
|
...history,
|
|
{
|
|
role: 'user',
|
|
content: message
|
|
}
|
|
]
|
|
})
|
|
});
|
|
} catch (error) {
|
|
const err = new Error(
|
|
error?.name === 'AbortError'
|
|
? 'Der Sprachassistent hat zu lange für eine Antwort gebraucht.'
|
|
: 'Der Sprachassistent konnte nicht erreicht werden.'
|
|
);
|
|
err.status = 502;
|
|
throw err;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
let responseData = null;
|
|
try {
|
|
responseData = await response.json();
|
|
} catch {
|
|
responseData = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const messageFromApi = responseData?.error?.message || responseData?.message || 'Der Sprachassistent hat die Anfrage abgelehnt.';
|
|
const err = new Error(messageFromApi);
|
|
err.status = response.status || 502;
|
|
throw err;
|
|
}
|
|
|
|
const reply = this._extractAssistantContent(responseData);
|
|
if (!reply) {
|
|
const err = new Error('Der Sprachassistent hat keine verwertbare Antwort geliefert.');
|
|
err.status = 502;
|
|
throw err;
|
|
}
|
|
|
|
return {
|
|
reply,
|
|
model: responseData?.model || config.model,
|
|
mode
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
|
|
*/
|
|
async _getReviewLessons(courseId, currentLessonNumber) {
|
|
const lessons = await VocabCourseLesson.findAll({
|
|
where: {
|
|
courseId: courseId,
|
|
lessonNumber: {
|
|
[Op.lt]: currentLessonNumber // Nur Lektionen mit kleinerer Nummer
|
|
},
|
|
lessonType: {
|
|
[Op.notIn]: ['review', 'vocab_review'] // Keine anderen Wiederholungslektionen
|
|
}
|
|
},
|
|
order: [['lessonNumber', 'ASC']],
|
|
attributes: ['id', 'lessonNumber', 'title']
|
|
});
|
|
return lessons.map(l => l.get({ plain: true }));
|
|
}
|
|
|
|
/**
|
|
* Sammelt alle Grammatik-Übungen aus vorherigen Lektionen für Wiederholungslektionen
|
|
*/
|
|
async _getReviewVocabExercises(courseId, currentLessonNumber) {
|
|
const previousLessons = await VocabCourseLesson.findAll({
|
|
where: {
|
|
courseId: courseId,
|
|
lessonNumber: {
|
|
[Op.lt]: currentLessonNumber
|
|
},
|
|
lessonType: {
|
|
[Op.notIn]: ['review', 'vocab_review']
|
|
}
|
|
},
|
|
attributes: ['id']
|
|
});
|
|
|
|
if (previousLessons.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const lessonIds = previousLessons.map(l => l.id);
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: {
|
|
lessonId: {
|
|
[Op.in]: lessonIds
|
|
}
|
|
},
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
},
|
|
{
|
|
model: VocabCourseLesson,
|
|
as: 'lesson',
|
|
attributes: ['id', 'lessonNumber', 'title']
|
|
}
|
|
],
|
|
order: [
|
|
[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC'],
|
|
['exerciseNumber', 'ASC']
|
|
]
|
|
});
|
|
|
|
return exercises.map(e => e.get({ plain: true }));
|
|
}
|
|
|
|
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can add lessons');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe, ob Kapitel zur gleichen Sprache gehört (nur wenn chapterId angegeben)
|
|
if (chapterId) {
|
|
const [chapter] = await sequelize.query(
|
|
`SELECT language_id FROM community.vocab_chapter WHERE id = :chapterId`,
|
|
{
|
|
replacements: { chapterId: Number(chapterId) },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
|
|
if (!chapter || chapter.language_id !== course.languageId) {
|
|
const err = new Error('Chapter does not belong to the course language');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const lesson = await VocabCourseLesson.create({
|
|
courseId: course.id,
|
|
chapterId: chapterId ? Number(chapterId) : null,
|
|
lessonNumber: Number(lessonNumber),
|
|
title,
|
|
description,
|
|
weekNumber: weekNumber ? Number(weekNumber) : null,
|
|
dayNumber: dayNumber ? Number(dayNumber) : null,
|
|
lessonType: lessonType || 'vocab',
|
|
didacticMode: this._normalizeOptionalString(didacticMode),
|
|
phaseLabel: this._normalizeOptionalString(phaseLabel),
|
|
blockNumber: this._normalizeOptionalInteger(blockNumber),
|
|
difficultyWeight: this._normalizeOptionalInteger(difficultyWeight),
|
|
newUnitTarget: this._normalizeOptionalInteger(newUnitTarget),
|
|
reviewWeight: this._normalizeOptionalInteger(reviewWeight),
|
|
isIntensiveReview: isIntensiveReview !== undefined ? Boolean(isIntensiveReview) : false,
|
|
audioUrl: audioUrl || null,
|
|
culturalNotes: culturalNotes || null,
|
|
learningGoals: this._normalizeStringList(learningGoals),
|
|
corePatterns: this._normalizeCorePatternList(corePatterns),
|
|
grammarFocus: this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']),
|
|
speakingPrompts: this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']),
|
|
practicalTasks: this._normalizeStructuredList(practicalTasks, ['title', 'text']),
|
|
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
|
|
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
|
|
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
|
|
});
|
|
|
|
return lesson.get({ plain: true });
|
|
}
|
|
|
|
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can update lessons');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const updates = {};
|
|
if (title !== undefined) updates.title = title;
|
|
if (description !== undefined) updates.description = description;
|
|
if (lessonNumber !== undefined) updates.lessonNumber = Number(lessonNumber);
|
|
if (weekNumber !== undefined) updates.weekNumber = weekNumber ? Number(weekNumber) : null;
|
|
if (dayNumber !== undefined) updates.dayNumber = dayNumber ? Number(dayNumber) : null;
|
|
if (lessonType !== undefined) updates.lessonType = lessonType;
|
|
if (didacticMode !== undefined) updates.didacticMode = this._normalizeOptionalString(didacticMode);
|
|
if (phaseLabel !== undefined) updates.phaseLabel = this._normalizeOptionalString(phaseLabel);
|
|
if (blockNumber !== undefined) updates.blockNumber = this._normalizeOptionalInteger(blockNumber);
|
|
if (difficultyWeight !== undefined) updates.difficultyWeight = this._normalizeOptionalInteger(difficultyWeight);
|
|
if (newUnitTarget !== undefined) updates.newUnitTarget = this._normalizeOptionalInteger(newUnitTarget);
|
|
if (reviewWeight !== undefined) updates.reviewWeight = this._normalizeOptionalInteger(reviewWeight);
|
|
if (isIntensiveReview !== undefined) updates.isIntensiveReview = Boolean(isIntensiveReview);
|
|
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
|
|
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
|
|
if (learningGoals !== undefined) updates.learningGoals = this._normalizeStringList(learningGoals);
|
|
if (corePatterns !== undefined) updates.corePatterns = this._normalizeCorePatternList(corePatterns);
|
|
if (grammarFocus !== undefined) updates.grammarFocus = this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']);
|
|
if (speakingPrompts !== undefined) updates.speakingPrompts = this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']);
|
|
if (practicalTasks !== undefined) updates.practicalTasks = this._normalizeStructuredList(practicalTasks, ['title', 'text']);
|
|
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
|
|
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
|
|
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
|
|
|
|
await lesson.update(updates);
|
|
return lesson.get({ plain: true });
|
|
}
|
|
|
|
async deleteLesson(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can delete lessons');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
await lesson.destroy();
|
|
return { success: true };
|
|
}
|
|
|
|
async enrollInCourse(hashedUserId, courseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (course.ownerUserId !== user.id && !course.isPublic) {
|
|
const err = new Error('Course is not public');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const [enrollment, created] = await VocabCourseEnrollment.findOrCreate({
|
|
where: { userId: user.id, courseId: course.id },
|
|
defaults: { userId: user.id, courseId: course.id }
|
|
});
|
|
|
|
if (!created) {
|
|
const err = new Error('Already enrolled in this course');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
return enrollment.get({ plain: true });
|
|
}
|
|
|
|
async unenrollFromCourse(hashedUserId, courseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: Number(courseId) }
|
|
});
|
|
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
await enrollment.destroy();
|
|
return { success: true };
|
|
}
|
|
|
|
async getMyCourses(hashedUserId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
|
|
const enrollments = await VocabCourseEnrollment.findAll({
|
|
where: { userId: user.id },
|
|
include: [{ model: VocabCourse, as: 'course' }],
|
|
order: [['enrolledAt', 'DESC']]
|
|
});
|
|
|
|
return enrollments.map(e => ({
|
|
...e.course.get({ plain: true }),
|
|
enrolledAt: e.enrolledAt
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Kurse, in die der Nutzer (per Hash) eingeschrieben ist — jede courseId nur einmal,
|
|
* bei mehrfachen Einschreibungen zählt die jeweils neueste Zeile.
|
|
*/
|
|
async listEnrolledVocabCoursesForUser(targetHashedUserId) {
|
|
const user = await this._getUserByHashedId(targetHashedUserId);
|
|
|
|
const enrollments = await VocabCourseEnrollment.findAll({
|
|
where: { userId: user.id },
|
|
include: [{ model: VocabCourse, as: 'course', required: true }],
|
|
order: [['enrolledAt', 'DESC']]
|
|
});
|
|
|
|
const byCourseId = new Map();
|
|
for (const e of enrollments) {
|
|
const row = e.course;
|
|
if (!row) {
|
|
continue;
|
|
}
|
|
const plain = row.get({ plain: true });
|
|
if (byCourseId.has(plain.id)) {
|
|
continue;
|
|
}
|
|
byCourseId.set(plain.id, {
|
|
...plain,
|
|
enrolledAt: e.enrolledAt
|
|
});
|
|
}
|
|
|
|
const coursesData = [...byCourseId.values()];
|
|
await this._attachLanguageNamesToCourseRows(coursesData);
|
|
return coursesData;
|
|
}
|
|
|
|
async getCourseProgress(hashedUserId, courseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
|
|
// Prüfe Einschreibung
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: Number(courseId) }
|
|
});
|
|
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const progress = await VocabCourseProgress.findAll({
|
|
where: { userId: user.id, courseId: Number(courseId) },
|
|
include: [{ model: VocabCourseLesson, as: 'lesson' }],
|
|
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']]
|
|
});
|
|
|
|
return progress.map((entry) => this._serializeLessonProgress(entry, entry.lesson));
|
|
}
|
|
|
|
async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes, lessonState }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Einschreibung
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: lesson.courseId }
|
|
});
|
|
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const lessonData = await VocabCourseLesson.findByPk(lesson.id);
|
|
const targetScore = lessonData.targetScorePercent || 80;
|
|
const actualScore = Number(score) || 0;
|
|
const hasReachedTarget = actualScore >= targetScore;
|
|
const sanitizedLessonState = lessonState === undefined ? undefined : this._sanitizeLessonState(lessonState);
|
|
const didSubmitResult = completed !== undefined || score !== undefined;
|
|
|
|
// Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true)
|
|
const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false);
|
|
|
|
const [progress, created] = await VocabCourseProgress.findOrCreate({
|
|
where: { userId: user.id, lessonId: lesson.id },
|
|
defaults: {
|
|
userId: user.id,
|
|
courseId: lesson.courseId,
|
|
lessonId: lesson.id,
|
|
completed: isCompleted,
|
|
score: actualScore,
|
|
lessonState: sanitizedLessonState || {},
|
|
lastAccessedAt: new Date()
|
|
}
|
|
});
|
|
|
|
if (!created) {
|
|
const updates = { lastAccessedAt: new Date() };
|
|
if (score !== undefined) {
|
|
updates.score = Math.max(progress.score, actualScore);
|
|
// Prüfe, ob Ziel jetzt erreicht wurde
|
|
if (updates.score >= targetScore && !progress.completed) {
|
|
if (!lessonData.requiresReview) {
|
|
updates.completed = true;
|
|
updates.completedAt = new Date();
|
|
}
|
|
}
|
|
}
|
|
if (completed !== undefined) {
|
|
updates.completed = Boolean(completed);
|
|
if (completed && !progress.completedAt) {
|
|
updates.completedAt = new Date();
|
|
}
|
|
}
|
|
if (sanitizedLessonState !== undefined) {
|
|
updates.lessonState = sanitizedLessonState;
|
|
}
|
|
const nextCompleted = updates.completed !== undefined ? Boolean(updates.completed) : Boolean(progress.completed);
|
|
const mergedLessonState = {
|
|
...this._sanitizeLessonState(progress.lessonState),
|
|
...(updates.lessonState || {})
|
|
};
|
|
updates.lessonState = this._applyScheduledReviewState(mergedLessonState, {
|
|
previousCompleted: Boolean(progress.completed),
|
|
nextCompleted,
|
|
shouldAdvanceReview: didSubmitResult && nextCompleted,
|
|
lessonData,
|
|
now: updates.completedAt || updates.lastAccessedAt || new Date()
|
|
});
|
|
await progress.update(updates);
|
|
} else if (isCompleted) {
|
|
progress.completed = true;
|
|
progress.completedAt = new Date();
|
|
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState || {}, {
|
|
previousCompleted: false,
|
|
nextCompleted: true,
|
|
shouldAdvanceReview: didSubmitResult,
|
|
lessonData,
|
|
now: progress.completedAt
|
|
});
|
|
await progress.save();
|
|
} else if (sanitizedLessonState !== undefined) {
|
|
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState, {
|
|
previousCompleted: false,
|
|
nextCompleted: false,
|
|
shouldAdvanceReview: false,
|
|
lessonData,
|
|
now: new Date()
|
|
});
|
|
await progress.save();
|
|
}
|
|
|
|
return this._serializeLessonProgress(progress, lessonData);
|
|
}
|
|
|
|
/**
|
|
* Löscht nur den Fortschritt zu einer Lektion (Zeile vocab_course_progress + zugehörige grammar-exercise-progress).
|
|
* Gesamtkurs / andere Lektionen bleiben unberührt.
|
|
*/
|
|
async _purgeLessonProgressForUser(userId, lessonId) {
|
|
const numericLessonId = Number(lessonId);
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: { lessonId: numericLessonId },
|
|
attributes: ['id']
|
|
});
|
|
const exerciseIds = exercises.map((e) => e.id);
|
|
let deletedExerciseProgressRows = 0;
|
|
if (exerciseIds.length > 0) {
|
|
deletedExerciseProgressRows = await VocabGrammarExerciseProgress.destroy({
|
|
where: { userId, exerciseId: { [Op.in]: exerciseIds } }
|
|
});
|
|
}
|
|
const deletedLessonProgressRows = await VocabCourseProgress.destroy({
|
|
where: { userId, lessonId: numericLessonId }
|
|
});
|
|
return {
|
|
success: true,
|
|
lessonId: numericLessonId,
|
|
deletedLessonProgressRows,
|
|
deletedExerciseProgressRows
|
|
};
|
|
}
|
|
|
|
/** Eingeloggter Nutzer setzt eigene Lektion zurück (nur bei Kurs-Einschreibung). */
|
|
async resetMyLessonProgress(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: lesson.courseId }
|
|
});
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
return this._purgeLessonProgressForUser(user.id, lesson.id);
|
|
}
|
|
|
|
/** Admin: Zielnutzer per Hash, ohne Einschreibungszwang (idempotentes Löschen). */
|
|
async adminResetLessonProgressForUser(targetHashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(targetHashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
return this._purgeLessonProgressForUser(user.id, lesson.id);
|
|
}
|
|
|
|
// ========== GRAMMAR EXERCISE METHODS ==========
|
|
|
|
async getExerciseTypes() {
|
|
const types = await VocabGrammarExerciseType.findAll({
|
|
order: [['name', 'ASC']]
|
|
});
|
|
return types.map(t => t.get({ plain: true }));
|
|
}
|
|
|
|
async createGrammarExercise(hashedUserId, lessonId, { exerciseTypeId, exerciseNumber, title, instruction, questionData, answerData, explanation }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe, ob User Besitzer des Kurses ist
|
|
if (lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can add grammar exercises');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const exercise = await VocabGrammarExercise.create({
|
|
lessonId: lesson.id,
|
|
exerciseTypeId: Number(exerciseTypeId),
|
|
exerciseNumber: Number(exerciseNumber),
|
|
title,
|
|
instruction,
|
|
questionData,
|
|
answerData,
|
|
explanation,
|
|
createdByUserId: user.id
|
|
});
|
|
|
|
return exercise.get({ plain: true });
|
|
}
|
|
|
|
async getGrammarExercisesForLesson(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: { lessonId: lesson.id },
|
|
include: [{ model: VocabGrammarExerciseType, as: 'exerciseType' }],
|
|
order: [['exerciseNumber', 'ASC']]
|
|
});
|
|
|
|
return exercises.map(e => e.get({ plain: true }));
|
|
}
|
|
|
|
async getGrammarExercise(hashedUserId, exerciseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] },
|
|
{ model: VocabGrammarExerciseType, as: 'exerciseType' }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (exercise.lesson.course.ownerUserId !== user.id && !exercise.lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
return exercise.get({ plain: true });
|
|
}
|
|
|
|
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Einschreibung
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: exercise.lesson.courseId }
|
|
});
|
|
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const originalAnswerData = typeof exercise.answerData === 'string'
|
|
? JSON.parse(exercise.answerData)
|
|
: exercise.answerData;
|
|
const questionData = typeof exercise.questionData === 'string'
|
|
? JSON.parse(exercise.questionData)
|
|
: exercise.questionData;
|
|
const effectiveAnswerData = exercise.exerciseTypeId === 2
|
|
? await this._expandMultipleChoiceAnswerData(exercise, originalAnswerData, questionData)
|
|
: originalAnswerData;
|
|
|
|
// Überprüfe Antwort
|
|
const isCorrect = this._checkAnswer(effectiveAnswerData, questionData, userAnswer, exercise.exerciseTypeId);
|
|
|
|
// Speichere Fortschritt
|
|
const [progress, created] = await VocabGrammarExerciseProgress.findOrCreate({
|
|
where: { userId: user.id, exerciseId: exercise.id },
|
|
defaults: {
|
|
userId: user.id,
|
|
exerciseId: exercise.id,
|
|
attempts: 1,
|
|
correctAttempts: isCorrect ? 1 : 0,
|
|
lastAttemptAt: new Date(),
|
|
completed: false
|
|
}
|
|
});
|
|
|
|
if (!created) {
|
|
progress.attempts += 1;
|
|
if (isCorrect) {
|
|
progress.correctAttempts += 1;
|
|
if (!progress.completed) {
|
|
progress.completed = true;
|
|
progress.completedAt = new Date();
|
|
}
|
|
}
|
|
progress.lastAttemptAt = new Date();
|
|
await progress.save();
|
|
} else if (isCorrect) {
|
|
progress.completed = true;
|
|
progress.completedAt = new Date();
|
|
await progress.save();
|
|
}
|
|
|
|
// Extrahiere richtige Antwort und Alternativen
|
|
const answerData = effectiveAnswerData;
|
|
|
|
let correctAnswer = null;
|
|
let alternatives = [];
|
|
|
|
// Für Multiple Choice: Extrahiere die richtige(n) Antwort(en) aus dem Index/den Indizes
|
|
if (exercise.exerciseTypeId === 2 && answerData.correctAnswer !== undefined) {
|
|
const options = questionData?.options || [];
|
|
|
|
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
|
|
let correctIndices = [];
|
|
if (Array.isArray(answerData.correctAnswer)) {
|
|
correctIndices = answerData.correctAnswer.map(idx => Number(idx));
|
|
} else {
|
|
correctIndices = [Number(answerData.correctAnswer)];
|
|
}
|
|
|
|
// Extrahiere alle korrekten Antworten
|
|
const correctAnswersList = correctIndices
|
|
.map(idx => options[idx])
|
|
.filter(opt => opt !== undefined);
|
|
|
|
if (correctAnswersList.length > 0) {
|
|
// Wenn mehrere richtige Antworten: Zeige alle an, getrennt durch " / "
|
|
correctAnswer = correctAnswersList.join(' / ');
|
|
}
|
|
|
|
// Alternativen sind alle anderen Optionen (nicht korrekte)
|
|
alternatives = options.filter((opt, idx) => !correctIndices.includes(idx));
|
|
}
|
|
// Für Gap Fill: Extrahiere aus answers Array
|
|
else if (exercise.exerciseTypeId === 1 && answerData.answers) {
|
|
correctAnswer = Array.isArray(answerData.answers)
|
|
? answerData.answers.join(', ')
|
|
: answerData.answers;
|
|
}
|
|
// Für Reading Aloud: Extrahiere den erwarteten Text
|
|
else if (questionData.type === 'reading_aloud') {
|
|
correctAnswer = questionData.text || answerData.expectedText || '';
|
|
}
|
|
// Für Speaking From Memory: Extrahiere erwarteten Text oder Schlüsselwörter
|
|
else if (questionData.type === 'speaking_from_memory') {
|
|
correctAnswer = questionData.expectedText || questionData.text || '';
|
|
alternatives = questionData.keywords || [];
|
|
}
|
|
else if (questionData.type === 'sentence_building' || questionData.type === 'dialog_completion' || questionData.type === 'situational_response' || questionData.type === 'pattern_drill') {
|
|
const rawCorrect = answerData.correct ?? answerData.correctAnswer ?? answerData.answers ?? answerData.modelAnswer;
|
|
if (Array.isArray(rawCorrect)) {
|
|
correctAnswer = rawCorrect.join(' / ');
|
|
} else {
|
|
correctAnswer = rawCorrect || questionData.modelAnswer || '';
|
|
}
|
|
alternatives = answerData.alternatives || questionData.keywords || [];
|
|
}
|
|
// Fallback: Versuche correct oder correctAnswer
|
|
else {
|
|
correctAnswer = Array.isArray(answerData.correct)
|
|
? answerData.correct[0]
|
|
: (answerData.correct || answerData.correctAnswer);
|
|
alternatives = answerData.alternatives || [];
|
|
}
|
|
|
|
return {
|
|
correct: isCorrect,
|
|
correctAnswer: correctAnswer,
|
|
alternatives: alternatives,
|
|
explanation: exercise.explanation,
|
|
progress: progress.get({ plain: true })
|
|
};
|
|
}
|
|
|
|
async _getExerciseTypeIdByName(typeName) {
|
|
const type = await VocabGrammarExerciseType.findOne({ where: { name: typeName } });
|
|
return type ? type.id : null;
|
|
}
|
|
|
|
_extractMultipleChoiceIndices(answerData) {
|
|
if (!answerData) return [];
|
|
if (answerData.correctAnswer !== undefined) {
|
|
return Array.isArray(answerData.correctAnswer)
|
|
? answerData.correctAnswer.map(idx => Number(idx)).filter(Number.isInteger)
|
|
: [Number(answerData.correctAnswer)].filter(Number.isInteger);
|
|
}
|
|
if (answerData.correct !== undefined) {
|
|
return Array.isArray(answerData.correct)
|
|
? answerData.correct.map(idx => Number(idx)).filter(Number.isInteger)
|
|
: [Number(answerData.correct)].filter(Number.isInteger);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
_getMultipleChoicePrompt(questionData) {
|
|
return this._normalizeTextAnswer(
|
|
questionData?.question || questionData?.text || questionData?.prompt || ''
|
|
);
|
|
}
|
|
|
|
async _expandMultipleChoiceAnswerData(exercise, answerData, questionData) {
|
|
const options = Array.isArray(questionData?.options) ? questionData.options : [];
|
|
const baseIndices = this._extractMultipleChoiceIndices(answerData);
|
|
if (!options.length || !baseIndices.length || !exercise?.lessonId) {
|
|
return answerData;
|
|
}
|
|
|
|
const prompt = this._getMultipleChoicePrompt(questionData);
|
|
if (!prompt) {
|
|
return answerData;
|
|
}
|
|
|
|
const optionIndexMap = new Map();
|
|
options.forEach((option, index) => {
|
|
const normalizedOption = this._normalizeTextAnswer(option);
|
|
if (!normalizedOption) return;
|
|
const existing = optionIndexMap.get(normalizedOption) || [];
|
|
existing.push(index);
|
|
optionIndexMap.set(normalizedOption, existing);
|
|
});
|
|
|
|
const lessonExercises = await VocabGrammarExercise.findAll({
|
|
where: {
|
|
lessonId: exercise.lessonId,
|
|
exerciseTypeId: 2
|
|
},
|
|
attributes: ['id', 'questionData', 'answerData']
|
|
});
|
|
|
|
const expandedIndices = new Set(baseIndices);
|
|
lessonExercises.forEach((candidate) => {
|
|
const candidateQuestionData = typeof candidate.questionData === 'string'
|
|
? JSON.parse(candidate.questionData)
|
|
: candidate.questionData;
|
|
const candidatePrompt = this._getMultipleChoicePrompt(candidateQuestionData);
|
|
if (candidatePrompt !== prompt) {
|
|
return;
|
|
}
|
|
|
|
const candidateAnswerData = typeof candidate.answerData === 'string'
|
|
? JSON.parse(candidate.answerData)
|
|
: candidate.answerData;
|
|
const candidateOptions = Array.isArray(candidateQuestionData?.options) ? candidateQuestionData.options : [];
|
|
const candidateIndices = this._extractMultipleChoiceIndices(candidateAnswerData);
|
|
|
|
candidateIndices.forEach((candidateIndex) => {
|
|
const candidateOption = candidateOptions[candidateIndex];
|
|
const normalizedOption = this._normalizeTextAnswer(candidateOption);
|
|
const matchingIndices = optionIndexMap.get(normalizedOption) || [];
|
|
matchingIndices.forEach((matchingIndex) => expandedIndices.add(matchingIndex));
|
|
});
|
|
});
|
|
|
|
return {
|
|
...answerData,
|
|
correctAnswer: Array.from(expandedIndices).sort((a, b) => a - b)
|
|
};
|
|
}
|
|
|
|
_checkAnswer(answerData, questionData, userAnswer, exerciseTypeId) {
|
|
// Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden
|
|
if (!answerData || userAnswer === undefined || userAnswer === null) return false;
|
|
|
|
// Parse JSON strings
|
|
const parsedAnswerData = typeof answerData === 'string' ? JSON.parse(answerData) : answerData;
|
|
const parsedQuestionData = typeof questionData === 'string' ? JSON.parse(questionData) : questionData;
|
|
|
|
// Für Multiple Choice: Prüfe ob userAnswer (Index) mit correctAnswer (Index oder Array von Indizes) übereinstimmt
|
|
if (exerciseTypeId === 2) { // multiple_choice
|
|
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
|
|
let correctIndices = [];
|
|
|
|
if (parsedAnswerData.correctAnswer !== undefined) {
|
|
// Kann ein einzelner Index oder ein Array von Indizes sein
|
|
if (Array.isArray(parsedAnswerData.correctAnswer)) {
|
|
correctIndices = parsedAnswerData.correctAnswer.map(idx => Number(idx));
|
|
} else {
|
|
correctIndices = [Number(parsedAnswerData.correctAnswer)];
|
|
}
|
|
} else if (parsedAnswerData.correct !== undefined) {
|
|
// Fallback: Prüfe auch 'correct' Feld
|
|
if (Array.isArray(parsedAnswerData.correct)) {
|
|
correctIndices = parsedAnswerData.correct.map(idx => Number(idx));
|
|
} else {
|
|
correctIndices = [Number(parsedAnswerData.correct)];
|
|
}
|
|
}
|
|
|
|
if (correctIndices.length === 0) return false;
|
|
|
|
const options = Array.isArray(parsedQuestionData?.options) ? parsedQuestionData.options : [];
|
|
|
|
const correctTexts = correctIndices
|
|
.map((i) => options[i])
|
|
.filter((opt) => opt !== undefined && opt !== null);
|
|
const norm = (s) => this._normalizeTextAnswer(s);
|
|
|
|
// Nach zufälligen Distraktoren: Client sendet gewählten Optionstext statt Index
|
|
if (typeof userAnswer === 'string') {
|
|
const u = norm(userAnswer);
|
|
if (!u) return false;
|
|
return correctTexts.some((t) => norm(t) === u);
|
|
}
|
|
|
|
// Legacy: Index in die gespeicherten (nicht gemischten) Optionen
|
|
const userIndex = Number(userAnswer);
|
|
if (Number.isNaN(userIndex)) return false;
|
|
return correctIndices.includes(userIndex);
|
|
}
|
|
|
|
// Für Lückentext: Normalisiere und vergleiche
|
|
if (exerciseTypeId === 1) { // gap_fill
|
|
const normalize = (str) => String(str || '').trim().toLowerCase();
|
|
const correctAnswers = parsedAnswerData.answers || parsedAnswerData.correct || [];
|
|
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
|
|
|
|
// userAnswer ist ein Array von Antworten
|
|
if (Array.isArray(userAnswer)) {
|
|
if (userAnswer.length !== correctAnswersArray.length) return false;
|
|
return userAnswer.every((ans, idx) => {
|
|
const correct = correctAnswersArray[idx];
|
|
return normalize(ans) === normalize(correct);
|
|
});
|
|
} else {
|
|
// Fallback: Einzelne Antwort
|
|
const normalizedUserAnswer = normalize(userAnswer);
|
|
return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer);
|
|
}
|
|
}
|
|
|
|
// Für Reading Aloud: userAnswer ist der erkannte Text (String)
|
|
// Vergleiche mit dem erwarteten Text aus questionData.text
|
|
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') {
|
|
const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
|
|
const normalizedExpected = this._normalizeTextAnswer(expectedText);
|
|
const normalizedUser = this._normalizeTextAnswer(userAnswer);
|
|
|
|
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
|
|
if (parsedQuestionData.type === 'reading_aloud') {
|
|
// Exakter Vergleich (kann später mit Levenshtein erweitert werden)
|
|
return normalizedUser === normalizedExpected;
|
|
}
|
|
|
|
// Für speaking_from_memory: Flexibler Vergleich (Schlüsselwörter)
|
|
if (parsedQuestionData.type === 'speaking_from_memory') {
|
|
const keywords = parsedQuestionData.keywords || [];
|
|
if (keywords.length === 0) {
|
|
// Fallback: Exakter Vergleich
|
|
return normalizedUser === normalizedExpected;
|
|
}
|
|
// Prüfe ob alle Schlüsselwörter vorhanden sind
|
|
return keywords.every(keyword => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
|
|
}
|
|
}
|
|
|
|
if (parsedQuestionData.type === 'sentence_building' || parsedQuestionData.type === 'dialog_completion' || parsedQuestionData.type === 'situational_response' || parsedQuestionData.type === 'pattern_drill') {
|
|
const candidateAnswers = parsedAnswerData.correct ?? parsedAnswerData.correctAnswer ?? parsedAnswerData.answers ?? parsedAnswerData.modelAnswer ?? [];
|
|
const normalizedUser = this._normalizeTextAnswer(userAnswer);
|
|
const answers = Array.isArray(candidateAnswers) ? candidateAnswers : [candidateAnswers];
|
|
|
|
if (parsedQuestionData.type === 'situational_response') {
|
|
const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || [];
|
|
if (keywords.length > 0) {
|
|
return keywords.every((keyword) => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
|
|
}
|
|
}
|
|
|
|
return answers
|
|
.map((answer) => this._normalizeTextAnswer(answer))
|
|
.filter(Boolean)
|
|
.some((answer) => answer === normalizedUser);
|
|
}
|
|
|
|
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
|
|
const correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
|
|
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
|
|
const normalizedUserAnswer = this._normalizeTextAnswer(userAnswer);
|
|
return correctAnswersArray.some(correct => this._normalizeTextAnswer(correct) === normalizedUserAnswer);
|
|
}
|
|
|
|
async getGrammarExerciseProgress(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId);
|
|
|
|
const exerciseIds = exercises.map(e => e.id);
|
|
const progress = await VocabGrammarExerciseProgress.findAll({
|
|
where: {
|
|
userId: user.id,
|
|
exerciseId: { [Op.in]: exerciseIds }
|
|
}
|
|
});
|
|
|
|
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
|
|
|
|
return exercises.map(exercise => ({
|
|
...exercise,
|
|
progress: progressMap.get(exercise.id) || null
|
|
}));
|
|
}
|
|
|
|
async updateGrammarExercise(hashedUserId, exerciseId, { title, instruction, questionData, answerData, explanation, exerciseNumber }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (exercise.lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can update exercises');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const updates = {};
|
|
if (title !== undefined) updates.title = title;
|
|
if (instruction !== undefined) updates.instruction = instruction;
|
|
if (questionData !== undefined) updates.questionData = questionData;
|
|
if (answerData !== undefined) updates.answerData = answerData;
|
|
if (explanation !== undefined) updates.explanation = explanation;
|
|
if (exerciseNumber !== undefined) updates.exerciseNumber = Number(exerciseNumber);
|
|
|
|
await exercise.update(updates);
|
|
return exercise.get({ plain: true });
|
|
}
|
|
|
|
async deleteGrammarExercise(hashedUserId, exerciseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (exercise.lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can delete exercises');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
await exercise.destroy();
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Ordnet eine Multiple-Choice-Frage der Zielsprache (zu lernen) oder Muttersprache (Erklärung) zu,
|
|
* damit Distraktoren aus dem passenden Wortpool gewählt werden können.
|
|
* @returns {'target'|'native'|'unknown'}
|
|
*/
|
|
_classifyMcQuestionSide(question) {
|
|
const q = String(question || '');
|
|
if (/Wie sagt man\s/i.test(q) || /Übersetze/i.test(q)) return 'target';
|
|
if (/Was bedeutet/i.test(q)) return 'native';
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Sammelt Vokabeln aus allen Multiple-Choice-Übungen von Lektionen **vor** der angegebenen Lektion
|
|
* (gleicher Kurs), getrennt nach Ziel- vs. Muttersprache anhand der Frageformulierung.
|
|
*/
|
|
async getVocabDistractorPool(hashedUserId, courseId, beforeLessonId) {
|
|
if (!beforeLessonId) {
|
|
const err = new Error('beforeLessonId is required');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: Number(courseId) },
|
|
});
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const currentLesson = await VocabCourseLesson.findByPk(beforeLessonId);
|
|
if (!currentLesson || currentLesson.courseId !== Number(courseId)) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
const priorLessons = await VocabCourseLesson.findAll({
|
|
where: {
|
|
courseId: Number(courseId),
|
|
lessonNumber: { [Op.lt]: currentLesson.lessonNumber },
|
|
},
|
|
attributes: ['id'],
|
|
order: [['lessonNumber', 'ASC']],
|
|
});
|
|
|
|
const lessonIds = priorLessons.map((l) => l.id);
|
|
if (lessonIds.length === 0) {
|
|
return { target: [], native: [] };
|
|
}
|
|
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: {
|
|
lessonId: { [Op.in]: lessonIds },
|
|
exerciseTypeId: 2,
|
|
},
|
|
attributes: ['questionData'],
|
|
});
|
|
|
|
const target = new Set();
|
|
const native = new Set();
|
|
|
|
for (const ex of exercises) {
|
|
const qd =
|
|
typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData;
|
|
const question = qd?.question || '';
|
|
const opts = qd?.options;
|
|
if (!Array.isArray(opts)) continue;
|
|
const side = this._classifyMcQuestionSide(question);
|
|
if (side === 'target') {
|
|
opts.forEach((o) => target.add(String(o).trim()));
|
|
} else if (side === 'native') {
|
|
opts.forEach((o) => native.add(String(o).trim()));
|
|
}
|
|
}
|
|
|
|
return {
|
|
target: [...target],
|
|
native: [...native],
|
|
};
|
|
}
|
|
}
|