All checks were successful
Deploy to production / deploy (push) Successful in 2m18s
- Introduced detailed logging throughout the sendLessonAssistantMessage method to track request lifecycle, including start, abort conditions, and response handling. - Improved error handling for various scenarios such as disabled assistant, unconfigured settings, empty messages, and fetch failures, providing clearer feedback to users. - Added logging for response parsing and upstream errors to facilitate debugging and improve overall service reliability.
4293 lines
142 KiB
JavaScript
4293 lines
142 KiB
JavaScript
import crypto from 'crypto';
|
|
import User from '../models/community/user.js';
|
|
import VocabCourse from '../models/community/vocab_course.js';
|
|
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
|
import VocabCourseEnrollment from '../models/community/vocab_course_enrollment.js';
|
|
import VocabCourseProgress from '../models/community/vocab_course_progress.js';
|
|
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
|
|
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
|
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
|
|
import VocabSrsItem from '../models/community/vocab_srs_item.js';
|
|
import UserParamType from '../models/type/user_param.js';
|
|
import UserParam from '../models/community/user_param.js';
|
|
import { sequelize } from '../utils/sequelize.js';
|
|
import { notifyUser } from '../utils/socket.js';
|
|
import { Op } from 'sequelize';
|
|
import { BISAYA_PHASE1_DIDACTICS, BISAYA_DIDACTICS_FRAGMENTS } from '../scripts/bisaya-course-phase1.js';
|
|
|
|
export default class VocabService {
|
|
_normalizeSrsText(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize('NFKC')
|
|
.replace(/[\p{P}\p{S}]+/gu, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
_buildSrsItemKey({ courseId, lessonId = null, learning, reference, direction = 'BOTH' }) {
|
|
const raw = [
|
|
Number(courseId) || 0,
|
|
lessonId == null ? 'course' : Number(lessonId) || 0,
|
|
String(direction || 'BOTH').toUpperCase(),
|
|
this._normalizeSrsText(learning),
|
|
this._normalizeSrsText(reference)
|
|
].join('|');
|
|
return crypto.createHash('sha1').update(raw).digest('hex');
|
|
}
|
|
|
|
_decorateSrsVocabs(vocabs = [], { courseId, lessonId = null } = {}) {
|
|
return (Array.isArray(vocabs) ? vocabs : [])
|
|
.map((entry) => {
|
|
const learning = String(entry?.learning || '').trim();
|
|
const reference = String(entry?.reference || '').trim();
|
|
if (!this._isTrainableSrsPair({ learning, reference })) {
|
|
return null;
|
|
}
|
|
const direction = String(entry?.direction || 'BOTH').toUpperCase();
|
|
const itemKey = this._buildSrsItemKey({ courseId, lessonId, learning, reference, direction });
|
|
return {
|
|
...entry,
|
|
id: entry?.id || itemKey,
|
|
itemKey,
|
|
courseId: Number(courseId) || null,
|
|
lessonId: lessonId == null ? null : Number(lessonId),
|
|
learning,
|
|
reference,
|
|
direction
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
_isInstructionLikeText(value) {
|
|
const text = String(value || '').trim();
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
|
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
if (wordCount < 3) {
|
|
return false;
|
|
}
|
|
|
|
const normalized = text.toLowerCase().normalize('NFKC');
|
|
const startsWithTaskVerb = /^(sage|sag|frage|frag|bitte|stelle|sprich|erzähle|erzaehle|beschreibe|bilde|wähle|waehle|ordne|übersetze|uebersetze|nenne|nenn|beginne|verwende|nutze|reagiere|kombiniere|spiele|löse|loese|beantworte|ergänze|ergaenze|formuliere|lies|entscheide|zeige)\b/i.test(normalized);
|
|
const startsWithTakeTask = /^nimm\b/i.test(normalized)
|
|
&& (
|
|
/\b(ein|eine|einen|zwei|drei|vier|fünf|fuenf|sechs|sieben|acht|neun|zehn|\d+)\b/i.test(normalized)
|
|
|| /\b(w[oö]rter|verben|gegenstände|gegenstaende|sätze|saetze|muster|beispiele)\b/i.test(normalized)
|
|
);
|
|
const containsTaskChain = /\b(und|,)\s*(sage|sag|frage|frag|bitte|stelle|sprich|erzähle|erzaehle|beschreibe|bilde|wähle|waehle|ordne|übersetze|uebersetze|nenne|nenn|verwende|nutze|reagiere|kombiniere|spiele|löse|loese|beantworte|ergänze|ergaenze|formuliere|lies|entscheide|zeige)\b/i.test(normalized);
|
|
const containsPracticeMarker = /\b(laut|jeweils|zu jedem|zu jeder|umgebung|alltagsszene|rollenspiel|mini-dialog|szene)\b/i.test(normalized);
|
|
|
|
return startsWithTaskVerb || startsWithTakeTask || (containsTaskChain && containsPracticeMarker);
|
|
}
|
|
|
|
_isTrainableSrsPair(entry) {
|
|
const learning = String(entry?.learning || '').trim();
|
|
const reference = String(entry?.reference || '').trim();
|
|
if (!learning || !reference || this._normalizeSrsText(learning) === this._normalizeSrsText(reference)) {
|
|
return false;
|
|
}
|
|
|
|
if (this._looksLikeFragmentMismatch(learning, reference)) {
|
|
return false;
|
|
}
|
|
|
|
return !this._isInstructionLikeText(learning) && !this._isInstructionLikeText(reference);
|
|
}
|
|
|
|
_wordCount(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/[\p{P}\p{S}]+/gu, ' ')
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.length;
|
|
}
|
|
|
|
_looksLikeFragmentMismatch(left, right) {
|
|
const leftWords = this._wordCount(left);
|
|
const rightWords = this._wordCount(right);
|
|
const leftText = String(left || '').trim();
|
|
const rightText = String(right || '').trim();
|
|
const leftLooksSentence = leftWords >= 3 || /[?!.].+\S/.test(leftText);
|
|
const rightLooksSentence = rightWords >= 3 || /[?!.].+\S/.test(rightText);
|
|
const leftLooksShortFragment = leftWords <= 1 && leftText.length <= 12;
|
|
const rightLooksShortFragment = rightWords <= 1 && rightText.length <= 12;
|
|
|
|
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
|
|
}
|
|
|
|
_calculateSrsSchedule(item, { correct, rating = null } = {}) {
|
|
const now = new Date();
|
|
const previousStage = Math.max(0, Number(item?.stage) || 0);
|
|
const previousInterval = Math.max(0, Number(item?.intervalDays) || 0);
|
|
const normalizedRating = String(rating || '').toLowerCase();
|
|
const isCorrect = Boolean(correct) && normalizedRating !== 'again';
|
|
|
|
if (!isCorrect) {
|
|
return {
|
|
stage: Math.max(0, previousStage - 1),
|
|
intervalDays: 0,
|
|
nextDueAt: new Date(now.getTime() + 10 * 60 * 1000),
|
|
lapseDelta: 1
|
|
};
|
|
}
|
|
|
|
const intervals = [0, 1, 3, 7, 14, 30, 60, 120, 240];
|
|
let nextStage = Math.min(intervals.length - 1, previousStage + 1);
|
|
|
|
if (normalizedRating === 'hard') {
|
|
nextStage = Math.max(1, previousStage);
|
|
}
|
|
if (normalizedRating === 'easy') {
|
|
nextStage = Math.min(intervals.length - 1, previousStage + 2);
|
|
}
|
|
|
|
let intervalDays = intervals[nextStage] ?? Math.max(1, previousInterval * 2);
|
|
if (normalizedRating === 'hard') {
|
|
intervalDays = Math.max(1, Math.ceil(Math.max(previousInterval, 1) * 1.2));
|
|
}
|
|
|
|
return {
|
|
stage: nextStage,
|
|
intervalDays,
|
|
nextDueAt: new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000),
|
|
lapseDelta: 0
|
|
};
|
|
}
|
|
|
|
async _ensureSrsItems(userId, { courseId, lessonId = null, vocabs = [] } = {}) {
|
|
const decorated = this._decorateSrsVocabs(vocabs, { courseId, lessonId });
|
|
if (!decorated.length) {
|
|
return [];
|
|
}
|
|
|
|
const existing = await VocabSrsItem.findAll({
|
|
where: {
|
|
userId,
|
|
itemKey: {
|
|
[Op.in]: decorated.map((entry) => entry.itemKey)
|
|
}
|
|
}
|
|
});
|
|
const existingByKey = new Map(existing.map((entry) => [entry.itemKey, entry]));
|
|
const now = new Date();
|
|
|
|
const createdItems = [];
|
|
for (const entry of decorated) {
|
|
if (existingByKey.has(entry.itemKey)) {
|
|
continue;
|
|
}
|
|
const created = await VocabSrsItem.create({
|
|
userId,
|
|
courseId: Number(courseId),
|
|
lessonId: entry.lessonId,
|
|
itemKey: entry.itemKey,
|
|
learning: entry.learning,
|
|
reference: entry.reference,
|
|
direction: entry.direction,
|
|
nextDueAt: now
|
|
});
|
|
createdItems.push(created);
|
|
existingByKey.set(entry.itemKey, created);
|
|
}
|
|
|
|
return decorated.map((entry) => {
|
|
const item = existingByKey.get(entry.itemKey);
|
|
return {
|
|
...entry,
|
|
srs: item ? {
|
|
stage: item.stage,
|
|
intervalDays: item.intervalDays,
|
|
lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt),
|
|
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
|
|
correctCount: item.correctCount,
|
|
wrongCount: item.wrongCount,
|
|
lapseCount: item.lapseCount,
|
|
isNew: createdItems.some((created) => created.itemKey === entry.itemKey)
|
|
} : null
|
|
};
|
|
});
|
|
}
|
|
|
|
_normalizeIsoDate(value) {
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '';
|
|
}
|
|
return date.toISOString();
|
|
}
|
|
|
|
_clampInteger(value, { min = 0, max = 100000, fallback = 0 } = {}) {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) {
|
|
return fallback;
|
|
}
|
|
return Math.max(min, Math.min(max, Math.trunc(numeric)));
|
|
}
|
|
|
|
/**
|
|
* Wörterbuch-API: page ab 1, pageSize max. 100, Standard 25.
|
|
*/
|
|
_parseDictionaryPaging(query = {}) {
|
|
const page = Math.max(1, this._clampInteger(query?.page, { min: 1, max: 1_000_000, fallback: 1 }));
|
|
const pageSize = this._clampInteger(query?.pageSize, { min: 1, max: 100, fallback: 25 });
|
|
return { page, pageSize };
|
|
}
|
|
|
|
_sanitizeShortString(value, maxLength = 400) {
|
|
const text = String(value ?? '').trim();
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
return text.slice(0, maxLength);
|
|
}
|
|
|
|
_sanitizeStringArray(value, { maxItems = 12, maxLength = 400, keepEmpty = false } = {}) {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return value
|
|
.slice(0, maxItems)
|
|
.map((entry) => this._sanitizeShortString(entry, maxLength))
|
|
.filter((entry) => keepEmpty || Boolean(entry));
|
|
}
|
|
|
|
_sanitizeExerciseAnswers(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const sanitized = {};
|
|
const synMcId = /^syn-\d+-\d+-l2r$/;
|
|
Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => {
|
|
const idStr = String(exerciseId);
|
|
if (!/^\d+$/.test(idStr) && !synMcId.test(idStr)) {
|
|
return;
|
|
}
|
|
if (Array.isArray(answer)) {
|
|
sanitized[idStr] = this._sanitizeStringArray(answer, {
|
|
maxItems: 12,
|
|
maxLength: 200,
|
|
keepEmpty: true
|
|
});
|
|
return;
|
|
}
|
|
if (typeof answer === 'string') {
|
|
sanitized[idStr] = this._sanitizeShortString(answer, 200);
|
|
return;
|
|
}
|
|
if (typeof answer === 'number' && Number.isFinite(answer)) {
|
|
sanitized[idStr] = Math.trunc(answer);
|
|
}
|
|
});
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
_sanitizeExerciseResults(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const synMcId = /^syn-\d+-\d+-l2r$/;
|
|
const sanitized = {};
|
|
Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => {
|
|
const idStr = String(exerciseId);
|
|
if ((!/^\d+$/.test(idStr) && !synMcId.test(idStr)) || !result || typeof result !== 'object' || Array.isArray(result)) {
|
|
return;
|
|
}
|
|
|
|
sanitized[idStr] = {
|
|
correct: Boolean(result.correct),
|
|
correctAnswer: this._sanitizeShortString(result.correctAnswer, 400),
|
|
alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }),
|
|
explanation: this._sanitizeShortString(result.explanation, 1200)
|
|
};
|
|
});
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
_sanitizeVocabTrainerStats(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const sanitized = {};
|
|
Object.entries(value).slice(0, 400).forEach(([key, stats]) => {
|
|
const safeKey = this._sanitizeShortString(key, 200);
|
|
if (!safeKey || !stats || typeof stats !== 'object' || Array.isArray(stats)) {
|
|
return;
|
|
}
|
|
|
|
sanitized[safeKey] = {
|
|
attempts: this._clampInteger(stats.attempts, { max: 5000 }),
|
|
correct: this._clampInteger(stats.correct, { max: 5000 }),
|
|
wrong: this._clampInteger(stats.wrong, { max: 5000 })
|
|
};
|
|
});
|
|
|
|
return sanitized;
|
|
}
|
|
|
|
_sanitizeRepeatQueue(value) {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.slice(0, 100)
|
|
.map((entry) => ({
|
|
key: this._sanitizeShortString(entry?.key, 200),
|
|
dueAfter: this._clampInteger(entry?.dueAfter, { min: 0, max: 50 }),
|
|
stageIndex: this._clampInteger(entry?.stageIndex, { min: 0, max: 10 })
|
|
}))
|
|
.filter((entry) => entry.key);
|
|
}
|
|
|
|
_sanitizeLessonState(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
|
|
const knownKeys = [
|
|
'version',
|
|
'updatedAt',
|
|
'activeTab',
|
|
'exercisePreparationCompleted',
|
|
'lessonPrepStage',
|
|
'lessonPrepIndex',
|
|
'vocabTrainerActive',
|
|
'vocabTrainerMode',
|
|
'vocabTrainerAutoSwitchedToTyping',
|
|
'vocabTrainerCorrect',
|
|
'vocabTrainerWrong',
|
|
'vocabTrainerTotalAttempts',
|
|
'vocabTrainerCurrentAttempts',
|
|
'vocabTrainerReviewAttempts',
|
|
'vocabTrainerStats',
|
|
'vocabTrainerRepeatQueue',
|
|
'exerciseAnswers',
|
|
'exerciseResults',
|
|
'exerciseRetryPending',
|
|
'exerciseRetryPendingSinceAttempts',
|
|
'reviewStage',
|
|
'reviewNextDueAt',
|
|
'reviewLastReviewedAt'
|
|
];
|
|
const hasKnownState = knownKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
if (!hasKnownState) {
|
|
return {};
|
|
}
|
|
|
|
const activeTab = value.activeTab === 'exercises' ? 'exercises' : 'learn';
|
|
const vocabTrainerMode = value.vocabTrainerMode === 'typing' ? 'typing' : 'multiple_choice';
|
|
|
|
return {
|
|
version: this._clampInteger(value.version, { min: 1, max: 1000, fallback: 1 }),
|
|
updatedAt: this._sanitizeShortString(value.updatedAt || new Date().toISOString(), 64),
|
|
activeTab,
|
|
exercisePreparationCompleted: Boolean(value.exercisePreparationCompleted),
|
|
lessonPrepStage: this._clampInteger(value.lessonPrepStage, { min: 0, max: 2 }),
|
|
lessonPrepIndex: this._clampInteger(value.lessonPrepIndex, { min: 0, max: 500 }),
|
|
vocabTrainerActive: Boolean(value.vocabTrainerActive),
|
|
vocabTrainerMode,
|
|
vocabTrainerAutoSwitchedToTyping: Boolean(value.vocabTrainerAutoSwitchedToTyping),
|
|
vocabTrainerCorrect: this._clampInteger(value.vocabTrainerCorrect, { max: 5000 }),
|
|
vocabTrainerWrong: this._clampInteger(value.vocabTrainerWrong, { max: 5000 }),
|
|
vocabTrainerTotalAttempts: this._clampInteger(value.vocabTrainerTotalAttempts, { max: 10000 }),
|
|
vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }),
|
|
vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }),
|
|
vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats),
|
|
vocabTrainerRepeatQueue: this._sanitizeRepeatQueue(value.vocabTrainerRepeatQueue),
|
|
exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers),
|
|
exerciseResults: this._sanitizeExerciseResults(value.exerciseResults),
|
|
exerciseRetryPending: Boolean(value.exerciseRetryPending),
|
|
exerciseRetryPendingSinceAttempts: this._clampInteger(value.exerciseRetryPendingSinceAttempts, { max: 10000 }),
|
|
reviewStage: this._clampInteger(value.reviewStage, { min: 0, max: 3 }),
|
|
reviewNextDueAt: this._normalizeIsoDate(value.reviewNextDueAt),
|
|
reviewLastReviewedAt: this._normalizeIsoDate(value.reviewLastReviewedAt)
|
|
};
|
|
}
|
|
|
|
_supportsScheduledReview(lessonData = null) {
|
|
const lessonType = String(lessonData?.lessonType || '').toLowerCase();
|
|
const didacticMode = String(lessonData?.didacticMode || '').toLowerCase();
|
|
if (lessonType === 'culture' || lessonType === 'review' || lessonType === 'vocab_review') {
|
|
return false;
|
|
}
|
|
if (didacticMode === 'intensive_review' || didacticMode === 'checkpoint') {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
_removeManagedReviewState(lessonState = {}) {
|
|
const nextState = { ...(lessonState || {}) };
|
|
delete nextState.reviewStage;
|
|
delete nextState.reviewNextDueAt;
|
|
delete nextState.reviewLastReviewedAt;
|
|
return nextState;
|
|
}
|
|
|
|
_applyScheduledReviewState(lessonState = {}, {
|
|
previousCompleted = false,
|
|
nextCompleted = false,
|
|
shouldAdvanceReview = false,
|
|
lessonData = null,
|
|
now = new Date()
|
|
} = {}) {
|
|
const baseState = this._sanitizeLessonState(lessonState);
|
|
if (!this._supportsScheduledReview(lessonData) || !nextCompleted) {
|
|
return this._removeManagedReviewState(baseState);
|
|
}
|
|
|
|
const reviewIntervalsDays = [1, 3, 7];
|
|
const currentStage = this._clampInteger(baseState.reviewStage, { min: 0, max: reviewIntervalsDays.length });
|
|
const dueAtIso = this._normalizeIsoDate(baseState.reviewNextDueAt);
|
|
const dueAt = dueAtIso ? new Date(dueAtIso) : null;
|
|
const reviewLastReviewedAt = this._normalizeIsoDate(baseState.reviewLastReviewedAt);
|
|
const nowIso = this._normalizeIsoDate(now);
|
|
|
|
const nextState = {
|
|
...baseState,
|
|
reviewStage: currentStage,
|
|
reviewNextDueAt: dueAtIso,
|
|
reviewLastReviewedAt
|
|
};
|
|
|
|
if (!previousCompleted && shouldAdvanceReview) {
|
|
nextState.reviewStage = 0;
|
|
nextState.reviewLastReviewedAt = nowIso;
|
|
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[0] * 24 * 60 * 60 * 1000));
|
|
return nextState;
|
|
}
|
|
|
|
if (!dueAtIso && currentStage < reviewIntervalsDays.length) {
|
|
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[currentStage] * 24 * 60 * 60 * 1000));
|
|
}
|
|
|
|
if (!shouldAdvanceReview || !dueAt || Number.isNaN(dueAt.getTime()) || now.getTime() < dueAt.getTime()) {
|
|
return nextState;
|
|
}
|
|
|
|
const nextStage = Math.min(reviewIntervalsDays.length, currentStage + 1);
|
|
nextState.reviewStage = nextStage;
|
|
nextState.reviewLastReviewedAt = nowIso;
|
|
nextState.reviewNextDueAt = nextStage >= reviewIntervalsDays.length
|
|
? ''
|
|
: this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[nextStage] * 24 * 60 * 60 * 1000));
|
|
return nextState;
|
|
}
|
|
|
|
_serializeLessonProgress(progress, lessonData = null, options = {}) {
|
|
if (!progress) {
|
|
return null;
|
|
}
|
|
|
|
const plainProgress = progress.get ? progress.get({ plain: true }) : { ...progress };
|
|
const targetScore = lessonData?.targetScorePercent || plainProgress.lesson?.targetScorePercent || 80;
|
|
const hasReachedTarget = (plainProgress.score || 0) >= targetScore;
|
|
const lessonState = this._sanitizeLessonState(plainProgress.lessonState);
|
|
const reviewStage = this._clampInteger(lessonState.reviewStage, { min: 0, max: 3 });
|
|
const reviewNextDueAt = this._normalizeIsoDate(lessonState.reviewNextDueAt);
|
|
const suppressLessonReviewDue = Boolean(options.suppressLessonReviewDue);
|
|
const reviewDue = !suppressLessonReviewDue && Boolean(reviewNextDueAt && reviewStage < 3 && new Date(reviewNextDueAt).getTime() <= Date.now());
|
|
|
|
return {
|
|
...plainProgress,
|
|
lessonId: Number(plainProgress.lessonId),
|
|
lessonNumber: lessonData?.lessonNumber ?? plainProgress.lesson?.lessonNumber ?? null,
|
|
lessonState,
|
|
targetScore,
|
|
hasReachedTarget,
|
|
needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget),
|
|
reviewStage,
|
|
reviewNextDueAt,
|
|
reviewDue,
|
|
reviewCompleted: reviewStage >= 3,
|
|
reviewSuppressedBySrs: suppressLessonReviewDue
|
|
};
|
|
}
|
|
|
|
async _courseHasDueSrsItems(userId, courseId) {
|
|
const dueItems = await VocabSrsItem.findAll({
|
|
where: {
|
|
userId,
|
|
courseId: Number(courseId),
|
|
nextDueAt: {
|
|
[Op.lte]: new Date()
|
|
}
|
|
},
|
|
attributes: ['learning', 'reference']
|
|
});
|
|
return dueItems.some((item) => this._isTrainableSrsPair(item));
|
|
}
|
|
|
|
async _getUserByHashedId(hashedUserId) {
|
|
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
|
if (!user) {
|
|
const err = new Error('User not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
return user;
|
|
}
|
|
|
|
async _attachLanguageNamesToCourseRows(coursesData) {
|
|
if (!coursesData.length) {
|
|
return;
|
|
}
|
|
const languageIds = [...new Set(coursesData.map((c) => c.languageId))];
|
|
if (languageIds.length > 0) {
|
|
const languages = await sequelize.query(
|
|
`SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`,
|
|
{
|
|
replacements: { languageIds },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
if (Array.isArray(languages)) {
|
|
const languageMap = new Map(languages.map((l) => [l.id, l.name]));
|
|
coursesData.forEach((c) => {
|
|
c.languageName = languageMap.get(c.languageId) || null;
|
|
});
|
|
}
|
|
}
|
|
const nativeLanguageIds = [...new Set(coursesData.map((c) => c.nativeLanguageId).filter((id) => id !== null))];
|
|
if (nativeLanguageIds.length > 0) {
|
|
const nativeLanguages = await sequelize.query(
|
|
`SELECT id, name FROM community.vocab_language WHERE id IN (:nativeLanguageIds)`,
|
|
{
|
|
replacements: { nativeLanguageIds },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
if (Array.isArray(nativeLanguages)) {
|
|
const nativeLanguageMap = new Map(nativeLanguages.map((l) => [l.id, l.name]));
|
|
coursesData.forEach((c) => {
|
|
c.nativeLanguageName = c.nativeLanguageId ? nativeLanguageMap.get(c.nativeLanguageId) || null : null;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async _getUserLlmConfig(userId) {
|
|
const [settingsType, apiKeyType] = await Promise.all([
|
|
UserParamType.findOne({ where: { description: 'llm_settings' } }),
|
|
UserParamType.findOne({ where: { description: 'llm_api_key' } })
|
|
]);
|
|
|
|
if (!settingsType || !apiKeyType) {
|
|
return {
|
|
enabled: false,
|
|
baseUrl: '',
|
|
model: 'gpt-4o-mini',
|
|
hasKey: false,
|
|
apiKey: null,
|
|
configured: false
|
|
};
|
|
}
|
|
|
|
const [settingsRow, keyRow] = await Promise.all([
|
|
UserParam.findOne({ where: { userId, paramTypeId: settingsType.id } }),
|
|
UserParam.findOne({ where: { userId, paramTypeId: apiKeyType.id } })
|
|
]);
|
|
|
|
let parsed = {};
|
|
if (settingsRow?.value) {
|
|
try {
|
|
parsed = JSON.parse(settingsRow.value);
|
|
} catch {
|
|
parsed = {};
|
|
}
|
|
}
|
|
|
|
const decryptedKey = keyRow?.value ? String(keyRow.value).trim() : null;
|
|
const hasKey = Boolean(decryptedKey && String(decryptedKey).trim());
|
|
const enabled = parsed.enabled !== false;
|
|
const baseUrl = String(parsed.baseUrl || '').trim();
|
|
|
|
return {
|
|
enabled,
|
|
baseUrl,
|
|
model: String(parsed.model || 'gpt-4o-mini').trim() || 'gpt-4o-mini',
|
|
hasKey,
|
|
apiKey: hasKey ? decryptedKey : null,
|
|
configured: enabled && (hasKey || Boolean(baseUrl))
|
|
};
|
|
}
|
|
|
|
_sanitizeAssistantHistory(history) {
|
|
if (!Array.isArray(history)) {
|
|
return [];
|
|
}
|
|
|
|
return history
|
|
.slice(-8)
|
|
.map((entry) => ({
|
|
role: entry?.role === 'assistant' ? 'assistant' : 'user',
|
|
content: String(entry?.content || '').trim()
|
|
}))
|
|
.filter((entry) => entry.content);
|
|
}
|
|
|
|
_buildLessonAssistantSystemPrompt(lesson, mode = 'practice') {
|
|
const didactics = lesson?.didactics || {};
|
|
const learningGoals = Array.isArray(didactics.learningGoals) ? didactics.learningGoals : [];
|
|
const corePatterns = Array.isArray(didactics.corePatterns) ? didactics.corePatterns : [];
|
|
const speakingPrompts = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts : [];
|
|
const practicalTasks = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks : [];
|
|
|
|
const modeDirectives = {
|
|
explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.',
|
|
practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.',
|
|
correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.'
|
|
};
|
|
|
|
return [
|
|
'Du bist ein didaktischer Sprachassistent innerhalb eines Sprachkurses.',
|
|
'Antworte auf Deutsch, aber verwende die Zielsprache der Lektion aktiv in Beispielen und Mini-Dialogen.',
|
|
modeDirectives[mode] || modeDirectives.practice,
|
|
'Halte Antworten kompakt, praxisnah und auf diese Lektion fokussiert.',
|
|
`Kurs: ${lesson?.course?.title || 'Unbekannter Kurs'}`,
|
|
`Lektion: ${lesson?.title || 'Unbekannte Lektion'}`,
|
|
lesson?.description ? `Beschreibung: ${lesson.description}` : '',
|
|
learningGoals.length ? `Lernziele: ${learningGoals.join(' | ')}` : '',
|
|
corePatterns.length
|
|
? `Kernmuster: ${corePatterns.map((p) => {
|
|
const n = this._normalizeCorePatternEntry(p);
|
|
if (!n) return '';
|
|
return n.gloss ? `${n.target} (${n.gloss})` : n.target;
|
|
}).filter(Boolean).join(' | ')}`
|
|
: '',
|
|
speakingPrompts.length
|
|
? `Sprechaufträge: ${speakingPrompts.map((item) => item.prompt || item.title || '').filter(Boolean).join(' | ')}`
|
|
: '',
|
|
practicalTasks.length
|
|
? `Praxisaufgaben: ${practicalTasks.map((item) => item.text || item.title || '').filter(Boolean).join(' | ')}`
|
|
: '',
|
|
'Wenn der Nutzer eine Formulierung versucht, korrigiere sie präzise und gib eine verbesserte Version.'
|
|
].filter(Boolean).join('\n');
|
|
}
|
|
|
|
_extractAssistantContent(responseData) {
|
|
const rawContent = responseData?.choices?.[0]?.message?.content;
|
|
if (typeof rawContent === 'string') {
|
|
return rawContent.trim();
|
|
}
|
|
if (Array.isArray(rawContent)) {
|
|
return rawContent
|
|
.map((item) => {
|
|
if (typeof item === 'string') return item;
|
|
if (item?.type === 'text') return item.text || '';
|
|
return '';
|
|
})
|
|
.join('\n')
|
|
.trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
_normalizeLexeme(text) {
|
|
return String(text || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
_normalizeTextAnswer(text) {
|
|
const normalized = String(text || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize('NFKC')
|
|
.replace(/[\p{P}\p{S}]+/gu, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return normalized.replace(/\s+/g, '');
|
|
}
|
|
|
|
_expandComparableAnswerVariants(text) {
|
|
const raw = String(text || '').trim();
|
|
if (!raw) return [];
|
|
const variants = new Set([raw]);
|
|
|
|
// Beispiel: "heute (heute am Tag)" -> "heute" und "heute am Tag"
|
|
const withoutParentheses = raw.replace(/\s*\([^)]*\)\s*/g, ' ').replace(/\s+/g, ' ').trim();
|
|
if (withoutParentheses) variants.add(withoutParentheses);
|
|
|
|
const parenMatches = raw.match(/\(([^)]+)\)/g) || [];
|
|
parenMatches.forEach((chunk) => {
|
|
const content = chunk.replace(/[()]/g, '').trim();
|
|
if (content) variants.add(content);
|
|
});
|
|
|
|
return Array.from(variants);
|
|
}
|
|
|
|
_isEquivalentAnswer(userAnswer, canonicalAnswer) {
|
|
const normalizedUser = this._normalizeTextAnswer(userAnswer);
|
|
if (!normalizedUser) return false;
|
|
const canonicalVariants = this._expandComparableAnswerVariants(canonicalAnswer)
|
|
.map((entry) => this._normalizeTextAnswer(entry))
|
|
.filter(Boolean);
|
|
return canonicalVariants.includes(normalizedUser);
|
|
}
|
|
|
|
_parseExercisePayload(value) {
|
|
if (!value) return {};
|
|
if (typeof value === 'string') {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
if (typeof value === 'object') {
|
|
return value;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
_extractTrainerVocabsFromExercises(exercises = []) {
|
|
const vocabMap = new Map();
|
|
|
|
exercises.forEach((exercise) => {
|
|
try {
|
|
const qData = this._parseExercisePayload(exercise.questionData);
|
|
const aData = this._parseExercisePayload(exercise.answerData);
|
|
const exerciseType = exercise.exerciseType?.name || qData.type || '';
|
|
|
|
if (exerciseType === 'multiple_choice') {
|
|
const options = Array.isArray(qData.options) ? qData.options : [];
|
|
const correctAnswer = Array.isArray(aData.correctAnswer)
|
|
? options[aData.correctAnswer[0]]
|
|
: options[aData.correctAnswer ?? aData.correct ?? 0];
|
|
const question = String(qData.question || qData.text || '');
|
|
|
|
let match = question.match(/Wie sagt man ['"]([^'"]+)['"]/i);
|
|
if (match && this._isTrainableSrsPair({ learning: match[1], reference: String(correctAnswer) })) {
|
|
vocabMap.set(`${match[1]}-${correctAnswer}`, {
|
|
learning: match[1],
|
|
reference: String(correctAnswer)
|
|
});
|
|
return;
|
|
}
|
|
|
|
match = question.match(/Was bedeutet ['"]([^'"]+)['"]/i);
|
|
if (match && this._isTrainableSrsPair({ learning: String(correctAnswer), reference: match[1] })) {
|
|
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 (!this._isTrainableSrsPair({ learning: nativeWord, reference: 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 corePatterns = Array.isArray(lesson?.corePatterns) ? lesson.corePatterns : [];
|
|
|
|
corePatterns.forEach((entry) => {
|
|
const pattern = this._normalizeCorePatternEntry(entry);
|
|
const reference = String(pattern?.target || '').trim();
|
|
const learning = String(pattern?.gloss || '').trim();
|
|
if (!this._isTrainableSrsPair({ learning, reference })) return;
|
|
vocabMap.set(`${learning}-${reference}`, { learning, reference });
|
|
});
|
|
|
|
return Array.from(vocabMap.values());
|
|
}
|
|
|
|
_normalizeStringList(value) {
|
|
if (!value) return [];
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.map((entry) => String(entry || '').trim())
|
|
.filter(Boolean);
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value
|
|
.split(/\r?\n|;/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Kernmuster: Zielsprachen-Phrase + optionale Glossierung (z. B. Deutsch).
|
|
* Unterstützt Legacy-Strings, "Phrase|Gloss" und Objekte { target, gloss } / { ceb, de }.
|
|
*/
|
|
_normalizeCorePatternEntry(entry) {
|
|
if (entry === null || entry === undefined || entry === '') {
|
|
return null;
|
|
}
|
|
if (typeof entry === 'object' && !Array.isArray(entry)) {
|
|
const target = String(entry.target ?? entry.ceb ?? entry.phrase ?? '').trim();
|
|
const gloss = String(entry.gloss ?? entry.de ?? entry.translation ?? '').trim();
|
|
if (!target) return null;
|
|
return { target, gloss };
|
|
}
|
|
const s = String(entry).trim();
|
|
if (!s) return null;
|
|
const pipe = s.indexOf('|');
|
|
if (pipe !== -1) {
|
|
const target = s.slice(0, pipe).trim();
|
|
const gloss = s.slice(pipe + 1).trim();
|
|
if (!target) return null;
|
|
return { target, gloss };
|
|
}
|
|
return { target: s, gloss: '' };
|
|
}
|
|
|
|
_normalizeCorePatternList(value) {
|
|
if (!value) return [];
|
|
const raw = Array.isArray(value)
|
|
? value
|
|
: (typeof value === 'string'
|
|
? value.split(/\r?\n|;/).map((entry) => entry.trim()).filter(Boolean)
|
|
: []);
|
|
return raw
|
|
.map((entry) => this._normalizeCorePatternEntry(entry))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
_corePatternTarget(entry) {
|
|
const n = this._normalizeCorePatternEntry(entry);
|
|
return n ? n.target : '';
|
|
}
|
|
|
|
_enrichCorePatternsWithGloss(corePatterns = [], extractedVocabs = []) {
|
|
const glossByReference = new Map();
|
|
|
|
extractedVocabs.forEach((item) => {
|
|
const reference = this._normalizeLexeme(item?.reference);
|
|
const learning = String(item?.learning || '').trim();
|
|
if (!reference || !learning) {
|
|
return;
|
|
}
|
|
if (!glossByReference.has(reference)) {
|
|
glossByReference.set(reference, learning);
|
|
}
|
|
});
|
|
|
|
return corePatterns
|
|
.map((entry) => this._normalizeCorePatternEntry(entry))
|
|
.filter(Boolean)
|
|
.map((entry) => {
|
|
if (entry.gloss) {
|
|
return entry;
|
|
}
|
|
const gloss = glossByReference.get(this._normalizeLexeme(entry.target)) || '';
|
|
return gloss ? { ...entry, gloss } : entry;
|
|
});
|
|
}
|
|
|
|
_mergeCorePatternGlosses(primaryPatterns = [], fallbackPatterns = []) {
|
|
const fallbackByTarget = new Map(
|
|
fallbackPatterns
|
|
.map((entry) => this._normalizeCorePatternEntry(entry))
|
|
.filter(Boolean)
|
|
.map((entry) => [this._normalizeLexeme(entry.target), entry.gloss || ''])
|
|
);
|
|
|
|
return primaryPatterns.map((entry) => {
|
|
const normalized = this._normalizeCorePatternEntry(entry);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
if (normalized.gloss) {
|
|
return normalized;
|
|
}
|
|
const gloss = fallbackByTarget.get(this._normalizeLexeme(normalized.target)) || '';
|
|
return gloss ? { ...normalized, gloss } : normalized;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
_normalizeStructuredList(value, keys = ['title', 'text']) {
|
|
if (!value) return [];
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.map((entry) => {
|
|
if (typeof entry === 'string') {
|
|
return { title: '', text: entry.trim() };
|
|
}
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
const normalized = {};
|
|
keys.forEach((key) => {
|
|
if (entry[key] !== undefined && entry[key] !== null) {
|
|
normalized[key] = String(entry[key]).trim();
|
|
}
|
|
});
|
|
return Object.keys(normalized).length > 0 ? normalized : null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
_normalizeOptionalInteger(value) {
|
|
if (value === undefined || value === null || value === '') {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
_normalizeOptionalString(value) {
|
|
if (value === undefined || value === null) {
|
|
return null;
|
|
}
|
|
const trimmed = String(value).trim();
|
|
return trimmed || null;
|
|
}
|
|
|
|
_inferLessonPhaseLabel(plainLesson) {
|
|
if (plainLesson.phaseLabel) {
|
|
return plainLesson.phaseLabel;
|
|
}
|
|
const weekNumber = Number(plainLesson.weekNumber) || 0;
|
|
if (weekNumber > 0 && weekNumber <= 2) {
|
|
return 'quickstart';
|
|
}
|
|
if (weekNumber === 3) {
|
|
return 'daily_life';
|
|
}
|
|
if (weekNumber >= 4) {
|
|
return 'stabilization';
|
|
}
|
|
return 'quickstart';
|
|
}
|
|
|
|
_inferLessonDidacticMode(plainLesson) {
|
|
const lessonType = String(plainLesson.lessonType || '').toLowerCase();
|
|
const title = String(plainLesson.title || '').toLowerCase();
|
|
const storedMode = String(plainLesson.didacticMode || '').trim();
|
|
|
|
const isContrastTraining = lessonType === 'grammar' && [
|
|
'kontrast',
|
|
'fehlertraining',
|
|
' / ',
|
|
'nicht / kein',
|
|
'der / die / das',
|
|
'wo / wohin',
|
|
'du / sie',
|
|
'haben / sein',
|
|
'ich bin / ich habe',
|
|
'ich bin / ich heiße / ich komme'
|
|
].some((marker) => title.includes(marker));
|
|
|
|
if (storedMode && storedMode !== 'pattern_drill') {
|
|
return storedMode;
|
|
}
|
|
if (isContrastTraining) {
|
|
return 'contrast_training';
|
|
}
|
|
if (storedMode) {
|
|
return storedMode;
|
|
}
|
|
if (title.includes('abschluss') || title.includes('prüfung') || title.includes('test')) {
|
|
return 'checkpoint';
|
|
}
|
|
if (plainLesson.isIntensiveReview || lessonType === 'review' || lessonType === 'vocab_review' || title.includes('wiederholung')) {
|
|
return 'intensive_review';
|
|
}
|
|
if (lessonType === 'grammar') {
|
|
return 'pattern_drill';
|
|
}
|
|
if (lessonType === 'conversation' || lessonType === 'dialogue' || lessonType === 'phrases' || lessonType === 'survival') {
|
|
return 'guided_dialogue';
|
|
}
|
|
if (lessonType === 'culture') {
|
|
return 'real_life_scenario';
|
|
}
|
|
return 'core_input';
|
|
}
|
|
|
|
_inferLessonDifficultyWeight(plainLesson, didacticMode) {
|
|
if (plainLesson.difficultyWeight != null) {
|
|
return plainLesson.difficultyWeight;
|
|
}
|
|
switch (didacticMode) {
|
|
case 'contrast_training':
|
|
case 'pattern_drill':
|
|
return 3;
|
|
case 'guided_dialogue':
|
|
case 'real_life_scenario':
|
|
return 2;
|
|
case 'intensive_review':
|
|
case 'checkpoint':
|
|
return 2;
|
|
default:
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
_inferLessonNewUnitTarget(plainLesson, didacticMode) {
|
|
if (plainLesson.newUnitTarget != null) {
|
|
return plainLesson.newUnitTarget;
|
|
}
|
|
switch (didacticMode) {
|
|
case 'contrast_training':
|
|
return 3;
|
|
case 'core_input':
|
|
return 8;
|
|
case 'guided_dialogue':
|
|
return 5;
|
|
case 'pattern_drill':
|
|
return 4;
|
|
case 'real_life_scenario':
|
|
return 3;
|
|
case 'checkpoint':
|
|
return 2;
|
|
case 'intensive_review':
|
|
return 1;
|
|
default:
|
|
return 4;
|
|
}
|
|
}
|
|
|
|
_inferLessonReviewWeight(plainLesson, didacticMode) {
|
|
if (plainLesson.reviewWeight != null) {
|
|
return plainLesson.reviewWeight;
|
|
}
|
|
switch (didacticMode) {
|
|
case 'intensive_review':
|
|
return 90;
|
|
case 'checkpoint':
|
|
return 70;
|
|
case 'contrast_training':
|
|
return 70;
|
|
case 'pattern_drill':
|
|
return 55;
|
|
case 'real_life_scenario':
|
|
return 45;
|
|
case 'guided_dialogue':
|
|
return 40;
|
|
default:
|
|
return 30;
|
|
}
|
|
}
|
|
|
|
_inferLessonBlockNumber(plainLesson) {
|
|
if (plainLesson.blockNumber != null) {
|
|
return plainLesson.blockNumber;
|
|
}
|
|
const weekNumber = Number(plainLesson.weekNumber) || 1;
|
|
return Math.max(1, Math.ceil(weekNumber / 2));
|
|
}
|
|
|
|
_buildLessonPedagogy(plainLesson) {
|
|
const didacticMode = this._inferLessonDidacticMode(plainLesson);
|
|
const phaseLabel = this._inferLessonPhaseLabel(plainLesson);
|
|
const isIntensiveReview = plainLesson.isIntensiveReview != null
|
|
? Boolean(plainLesson.isIntensiveReview)
|
|
: didacticMode === 'intensive_review';
|
|
|
|
return {
|
|
didacticMode,
|
|
phaseLabel,
|
|
blockNumber: this._inferLessonBlockNumber(plainLesson),
|
|
difficultyWeight: this._inferLessonDifficultyWeight(plainLesson, didacticMode),
|
|
newUnitTarget: this._inferLessonNewUnitTarget(plainLesson, didacticMode),
|
|
reviewWeight: this._inferLessonReviewWeight(plainLesson, didacticMode),
|
|
isIntensiveReview
|
|
};
|
|
}
|
|
|
|
_buildLessonDidactics(plainLesson) {
|
|
const grammarExercises = Array.isArray(plainLesson.grammarExercises) ? plainLesson.grammarExercises : [];
|
|
const grammarExplanations = [];
|
|
const patterns = [];
|
|
const speakingPrompts = [];
|
|
|
|
grammarExercises.forEach((exercise) => {
|
|
const questionData = typeof exercise.questionData === 'string'
|
|
? JSON.parse(exercise.questionData)
|
|
: (exercise.questionData || {});
|
|
|
|
if (exercise.explanation) {
|
|
grammarExplanations.push({
|
|
title: exercise.title || '',
|
|
text: exercise.explanation
|
|
});
|
|
}
|
|
|
|
const patternCandidates = [
|
|
questionData.pattern,
|
|
questionData.exampleSentence,
|
|
questionData.modelAnswer,
|
|
questionData.promptSentence
|
|
].filter(Boolean);
|
|
|
|
patternCandidates.forEach((candidate) => {
|
|
patterns.push(String(candidate).trim());
|
|
});
|
|
|
|
if (questionData.type === 'reading_aloud' || questionData.type === 'speaking_from_memory') {
|
|
speakingPrompts.push({
|
|
title: exercise.title || '',
|
|
prompt: questionData.question || questionData.text || '',
|
|
cue: questionData.expectedText || '',
|
|
keywords: Array.isArray(questionData.keywords) ? questionData.keywords : []
|
|
});
|
|
}
|
|
});
|
|
|
|
const uniqueGrammarExplanations = grammarExplanations.filter((item, index, list) => {
|
|
const signature = `${item.title}::${item.text}`;
|
|
return list.findIndex((entry) => `${entry.title}::${entry.text}` === signature) === index;
|
|
});
|
|
|
|
const uniquePatterns = [...new Set(patterns.map((item) => String(item || '').trim()).filter(Boolean))];
|
|
|
|
const learningGoals = this._normalizeStringList(plainLesson.learningGoals);
|
|
const extractedTrainerVocabs = this._extractTrainerVocabsFromExercises(grammarExercises);
|
|
const phase1FallbackCorePatterns = BISAYA_PHASE1_DIDACTICS[plainLesson.title]?.corePatterns || [];
|
|
let resolvedCorePatterns = this._mergeCorePatternGlosses(
|
|
this._enrichCorePatternsWithGloss(
|
|
this._normalizeCorePatternList(plainLesson.corePatterns),
|
|
extractedTrainerVocabs
|
|
),
|
|
phase1FallbackCorePatterns
|
|
);
|
|
if (
|
|
!resolvedCorePatterns.length
|
|
&& BISAYA_DIDACTICS_FRAGMENTS[plainLesson.title]?.corePatterns?.length
|
|
) {
|
|
const frag = BISAYA_DIDACTICS_FRAGMENTS[plainLesson.title];
|
|
resolvedCorePatterns = this._mergeCorePatternGlosses(
|
|
this._enrichCorePatternsWithGloss(
|
|
this._normalizeCorePatternList(frag.corePatterns),
|
|
extractedTrainerVocabs
|
|
),
|
|
phase1FallbackCorePatterns
|
|
);
|
|
}
|
|
const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']);
|
|
const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']);
|
|
const practicalTasks = this._normalizeStructuredList(plainLesson.practicalTasks, ['title', 'text']);
|
|
|
|
return {
|
|
learningGoals: learningGoals.length > 0
|
|
? learningGoals
|
|
: [
|
|
'Die Schlüsselausdrücke der Lektion verstehen und wiedererkennen.',
|
|
'Ein bis zwei Satzmuster aktiv anwenden.',
|
|
'Kurze Sätze oder Mini-Dialoge zum Thema selbst bilden.'
|
|
],
|
|
corePatterns: resolvedCorePatterns.length > 0
|
|
? resolvedCorePatterns
|
|
: this._mergeCorePatternGlosses(
|
|
this._enrichCorePatternsWithGloss(
|
|
uniquePatterns.slice(0, 5).map((s) => ({ target: String(s || '').trim(), gloss: '' })).filter((p) => p.target),
|
|
extractedTrainerVocabs
|
|
),
|
|
phase1FallbackCorePatterns
|
|
),
|
|
grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4),
|
|
speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4),
|
|
practicalTasks: practicalTasks.length > 0
|
|
? practicalTasks
|
|
: [
|
|
{
|
|
title: 'Mini-Anwendung',
|
|
text: 'Formuliere zwei bis drei eigene Sätze oder einen kurzen Dialog mit dem Muster dieser Lektion.'
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
async _getLanguageAccess(userId, languageId) {
|
|
const id = Number.parseInt(languageId, 10);
|
|
if (!Number.isFinite(id)) {
|
|
const err = new Error('Invalid language id');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [row] = await sequelize.query(
|
|
`
|
|
SELECT
|
|
l.id,
|
|
(l.owner_user_id = :userId) AS "isOwner"
|
|
FROM community.vocab_language l
|
|
WHERE l.id = :languageId
|
|
AND (
|
|
l.owner_user_id = :userId
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM community.vocab_language_subscription s
|
|
WHERE s.user_id = :userId AND s.language_id = l.id
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
{
|
|
replacements: { userId, languageId: id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
if (!row) {
|
|
const err = new Error('Language not found or no access');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
async _getChapterAccess(userId, chapterId) {
|
|
const id = Number.parseInt(chapterId, 10);
|
|
if (!Number.isFinite(id)) {
|
|
const err = new Error('Invalid chapter id');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [row] = await sequelize.query(
|
|
`
|
|
SELECT
|
|
c.id,
|
|
c.language_id AS "languageId",
|
|
c.title,
|
|
(l.owner_user_id = :userId) AS "isOwner"
|
|
FROM community.vocab_chapter c
|
|
JOIN community.vocab_language l ON l.id = c.language_id
|
|
WHERE c.id = :chapterId
|
|
AND (
|
|
l.owner_user_id = :userId
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM community.vocab_language_subscription s
|
|
WHERE s.user_id = :userId AND s.language_id = l.id
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
{
|
|
replacements: { userId, chapterId: id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
if (!row) {
|
|
const err = new Error('Chapter not found or no access');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
async listLanguages(hashedUserId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
l.id,
|
|
l.name,
|
|
l.share_code AS "shareCode",
|
|
TRUE AS "isOwner"
|
|
FROM community.vocab_language l
|
|
WHERE l.owner_user_id = :userId
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
l.id,
|
|
l.name,
|
|
NULL::text AS "shareCode",
|
|
FALSE AS "isOwner"
|
|
FROM community.vocab_language_subscription s
|
|
JOIN community.vocab_language l ON l.id = s.language_id
|
|
WHERE s.user_id = :userId
|
|
|
|
ORDER BY name ASC
|
|
`,
|
|
{
|
|
replacements: { userId: user.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { languages: rows };
|
|
}
|
|
|
|
async listAllLanguages() {
|
|
// Gibt alle verfügbaren Sprachen zurück (für Kursliste)
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
id,
|
|
name
|
|
FROM community.vocab_language
|
|
ORDER BY name ASC
|
|
`,
|
|
{
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { languages: rows };
|
|
}
|
|
|
|
async listLanguagesForMenu(userId) {
|
|
// userId ist die numerische community.user.id
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT l.id, l.name
|
|
FROM community.vocab_language l
|
|
WHERE l.owner_user_id = :userId
|
|
UNION
|
|
SELECT l.id, l.name
|
|
FROM community.vocab_language_subscription s
|
|
JOIN community.vocab_language l ON l.id = s.language_id
|
|
WHERE s.user_id = :userId
|
|
ORDER BY name ASC
|
|
`,
|
|
{
|
|
replacements: { userId },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
async createLanguage(hashedUserId, { name }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const cleanName = typeof name === 'string' ? name.trim() : '';
|
|
if (!cleanName || cleanName.length < 2 || cleanName.length > 60) {
|
|
const err = new Error('Invalid language name');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
// 16 hex chars => ausreichend kurz, gut teilbar
|
|
const shareCode = crypto.randomBytes(8).toString('hex');
|
|
|
|
const [created] = await sequelize.query(
|
|
`
|
|
INSERT INTO community.vocab_language (owner_user_id, name, share_code)
|
|
VALUES (:ownerUserId, :name, :shareCode)
|
|
RETURNING id, name, share_code AS "shareCode"
|
|
`,
|
|
{
|
|
replacements: { ownerUserId: user.id, name: cleanName, shareCode },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
// Menü dynamisch nachladen (bei allen offenen Tabs/Clients)
|
|
try {
|
|
notifyUser(user.hashedId, 'reloadmenu', {});
|
|
} catch (_) {}
|
|
|
|
return created;
|
|
}
|
|
|
|
async subscribeByShareCode(hashedUserId, { shareCode }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
|
|
if (!code || code.length < 6 || code.length > 128) {
|
|
const err = new Error('Invalid share code');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [lang] = await sequelize.query(
|
|
`
|
|
SELECT id, owner_user_id AS "ownerUserId", name
|
|
FROM community.vocab_language
|
|
WHERE share_code = :shareCode
|
|
LIMIT 1
|
|
`,
|
|
{
|
|
replacements: { shareCode: code },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
if (!lang) {
|
|
const err = new Error('Language not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Owner braucht kein Abo
|
|
if (lang.ownerUserId === user.id) {
|
|
return { subscribed: false, message: 'Already owner', languageId: lang.id };
|
|
}
|
|
|
|
await sequelize.query(
|
|
`
|
|
INSERT INTO community.vocab_language_subscription (user_id, language_id)
|
|
VALUES (:userId, :languageId)
|
|
ON CONFLICT (user_id, language_id) DO NOTHING
|
|
`,
|
|
{
|
|
replacements: { userId: user.id, languageId: lang.id },
|
|
type: sequelize.QueryTypes.INSERT,
|
|
}
|
|
);
|
|
|
|
try {
|
|
notifyUser(user.hashedId, 'reloadmenu', {});
|
|
} catch (_) {}
|
|
|
|
return { subscribed: true, languageId: lang.id, name: lang.name };
|
|
}
|
|
|
|
async getLanguage(hashedUserId, languageId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const id = Number.parseInt(languageId, 10);
|
|
if (!Number.isFinite(id)) {
|
|
const err = new Error('Invalid language id');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [row] = await sequelize.query(
|
|
`
|
|
SELECT
|
|
l.id,
|
|
l.name,
|
|
CASE WHEN l.owner_user_id = :userId THEN l.share_code ELSE NULL END AS "shareCode",
|
|
(l.owner_user_id = :userId) AS "isOwner"
|
|
FROM community.vocab_language l
|
|
WHERE l.id = :languageId
|
|
AND (
|
|
l.owner_user_id = :userId
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM community.vocab_language_subscription s
|
|
WHERE s.user_id = :userId AND s.language_id = l.id
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
{
|
|
replacements: { userId: user.id, languageId: id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
if (!row) {
|
|
const err = new Error('Language not found or no access');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
async listChapters(hashedUserId, languageId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const access = await this._getLanguageAccess(user.id, languageId);
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
c.id,
|
|
c.title,
|
|
c.created_at AS "createdAt",
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM community.vocab_chapter_lexeme cl
|
|
WHERE cl.chapter_id = c.id
|
|
)::int AS "vocabCount"
|
|
FROM community.vocab_chapter c
|
|
WHERE c.language_id = :languageId
|
|
ORDER BY c.title ASC
|
|
`,
|
|
{
|
|
replacements: { languageId: access.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { chapters: rows, isOwner: access.isOwner };
|
|
}
|
|
|
|
async createChapter(hashedUserId, languageId, { title }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const access = await this._getLanguageAccess(user.id, languageId);
|
|
if (!access.isOwner) {
|
|
const err = new Error('Only owner can create chapters');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const cleanTitle = typeof title === 'string' ? title.trim() : '';
|
|
if (!cleanTitle || cleanTitle.length < 2 || cleanTitle.length > 80) {
|
|
const err = new Error('Invalid chapter title');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const [created] = await sequelize.query(
|
|
`
|
|
INSERT INTO community.vocab_chapter (language_id, title, created_by_user_id)
|
|
VALUES (:languageId, :title, :userId)
|
|
RETURNING id, title, created_at AS "createdAt"
|
|
`,
|
|
{
|
|
replacements: { languageId: access.id, title: cleanTitle, userId: user.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return created;
|
|
}
|
|
|
|
async getChapter(hashedUserId, chapterId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const ch = await this._getChapterAccess(user.id, chapterId);
|
|
return { id: ch.id, languageId: ch.languageId, title: ch.title, isOwner: ch.isOwner };
|
|
}
|
|
|
|
async listChapterVocabs(hashedUserId, chapterId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const ch = await this._getChapterAccess(user.id, chapterId);
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
cl.id,
|
|
l1.text AS "learning",
|
|
l2.text AS "reference",
|
|
cl.created_at AS "createdAt"
|
|
FROM community.vocab_chapter_lexeme cl
|
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
|
WHERE cl.chapter_id = :chapterId
|
|
ORDER BY l1.text ASC, l2.text ASC
|
|
`,
|
|
{
|
|
replacements: { chapterId: ch.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { chapter: { id: ch.id, title: ch.title, languageId: ch.languageId, isOwner: ch.isOwner }, vocabs: rows };
|
|
}
|
|
|
|
async listLanguageVocabs(hashedUserId, languageId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const access = await this._getLanguageAccess(user.id, languageId);
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
cl.id,
|
|
c.id AS "chapterId",
|
|
c.title AS "chapterTitle",
|
|
l1.text AS "learning",
|
|
l2.text AS "reference",
|
|
cl.created_at AS "createdAt"
|
|
FROM community.vocab_chapter_lexeme cl
|
|
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
|
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
|
WHERE c.language_id = :languageId
|
|
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
|
|
`,
|
|
{
|
|
replacements: { languageId: access.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
|
|
}
|
|
|
|
async getLessonVocabPool(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [
|
|
{
|
|
model: VocabCourse,
|
|
as: 'course'
|
|
},
|
|
{
|
|
model: VocabGrammarExercise,
|
|
as: 'grammarExercises',
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
}
|
|
],
|
|
required: false
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const progress = await VocabCourseProgress.findOne({
|
|
where: {
|
|
userId: user.id,
|
|
lessonId: lesson.id
|
|
}
|
|
});
|
|
|
|
if (lesson.course.ownerUserId !== user.id && !progress?.completed) {
|
|
const err = new Error('Lesson must be completed first');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const extractedFromExercises = this._extractTrainerVocabsFromExercises(
|
|
(lesson.grammarExercises || []).map((exercise) => exercise.get({ plain: true }))
|
|
);
|
|
const fallbackVocabs = this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }));
|
|
const mergedVocabs = new Map();
|
|
[...fallbackVocabs, ...extractedFromExercises].forEach((entry) => {
|
|
if (!entry?.learning || !entry?.reference) return;
|
|
mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry);
|
|
});
|
|
const vocabs = await this._ensureSrsItems(user.id, {
|
|
courseId: lesson.courseId,
|
|
lessonId: lesson.id,
|
|
vocabs: Array.from(mergedVocabs.values())
|
|
});
|
|
|
|
return {
|
|
lesson: {
|
|
id: lesson.id,
|
|
title: lesson.title,
|
|
courseId: lesson.courseId,
|
|
courseTitle: lesson.course.title
|
|
},
|
|
vocabs
|
|
};
|
|
}
|
|
|
|
async getCompletedLessonVocabPool(hashedUserId, courseId, untilLessonId = null) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (course.ownerUserId !== user.id && !course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
let maxLessonNumber = null;
|
|
if (untilLessonId) {
|
|
const untilLesson = await VocabCourseLesson.findOne({
|
|
where: {
|
|
id: untilLessonId,
|
|
courseId: course.id
|
|
},
|
|
attributes: ['lessonNumber']
|
|
});
|
|
|
|
if (!untilLesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
maxLessonNumber = untilLesson.lessonNumber;
|
|
}
|
|
|
|
const completedProgress = await VocabCourseProgress.findAll({
|
|
where: {
|
|
userId: user.id,
|
|
courseId: course.id,
|
|
completed: true
|
|
},
|
|
attributes: ['lessonId']
|
|
});
|
|
|
|
const completedLessonIds = completedProgress.map((entry) => entry.lessonId);
|
|
if (completedLessonIds.length === 0) {
|
|
return { courseId: course.id, vocabs: [] };
|
|
}
|
|
|
|
const lessonWhere = {
|
|
id: {
|
|
[Op.in]: completedLessonIds
|
|
},
|
|
courseId: course.id
|
|
};
|
|
|
|
if (maxLessonNumber != null) {
|
|
lessonWhere.lessonNumber = {
|
|
[Op.lte]: maxLessonNumber
|
|
};
|
|
}
|
|
|
|
const lessons = await VocabCourseLesson.findAll({
|
|
where: lessonWhere,
|
|
attributes: ['id', 'speakingPrompts', 'practicalTasks', 'corePatterns'],
|
|
order: [['lessonNumber', 'ASC']]
|
|
});
|
|
|
|
const lessonIds = lessons.map((lesson) => lesson.id);
|
|
if (!lessonIds.length) {
|
|
return { courseId: course.id, vocabs: [] };
|
|
}
|
|
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: {
|
|
lessonId: {
|
|
[Op.in]: lessonIds
|
|
}
|
|
},
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
}
|
|
],
|
|
order: [['lessonId', 'ASC'], ['exerciseNumber', 'ASC']]
|
|
});
|
|
|
|
const extractedFromExercises = this._extractTrainerVocabsFromExercises(exercises.map((exercise) => exercise.get({ plain: true })));
|
|
const fallbackVocabs = lessons.flatMap((lesson) =>
|
|
this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }))
|
|
);
|
|
const mergedVocabs = new Map();
|
|
[...extractedFromExercises, ...fallbackVocabs].forEach((entry) => {
|
|
if (!entry?.learning || !entry?.reference) return;
|
|
mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry);
|
|
});
|
|
|
|
const vocabs = await this._ensureSrsItems(user.id, {
|
|
courseId: course.id,
|
|
lessonId: null,
|
|
vocabs: Array.from(mergedVocabs.values())
|
|
});
|
|
|
|
return {
|
|
courseId: course.id,
|
|
vocabs
|
|
};
|
|
}
|
|
|
|
async getCourseSrsDue(hashedUserId, courseId, query = {}) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (course.ownerUserId !== user.id && !course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const limit = this._clampInteger(query?.limit, { min: 1, max: 100, fallback: 30 });
|
|
const now = new Date();
|
|
const dueWhere = {
|
|
userId: user.id,
|
|
courseId: Number(course.id),
|
|
nextDueAt: {
|
|
[Op.lte]: now
|
|
}
|
|
};
|
|
const dueRows = await VocabSrsItem.findAll({
|
|
where: dueWhere,
|
|
order: [
|
|
['nextDueAt', 'ASC'],
|
|
['wrongCount', 'DESC'],
|
|
['stage', 'ASC']
|
|
]
|
|
});
|
|
const validDueRows = dueRows.filter((item) => this._isTrainableSrsPair(item));
|
|
const rows = validDueRows.slice(0, limit);
|
|
const totalDueCount = validDueRows.length;
|
|
|
|
return {
|
|
courseId: course.id,
|
|
dueAt: now.toISOString(),
|
|
count: rows.length,
|
|
totalDueCount,
|
|
limit,
|
|
items: rows.map((item) => ({
|
|
itemKey: item.itemKey,
|
|
courseId: item.courseId,
|
|
lessonId: item.lessonId,
|
|
learning: item.learning,
|
|
reference: item.reference,
|
|
direction: item.direction,
|
|
stage: item.stage,
|
|
intervalDays: item.intervalDays,
|
|
lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt),
|
|
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
|
|
correctCount: item.correctCount,
|
|
wrongCount: item.wrongCount,
|
|
lapseCount: item.lapseCount
|
|
}))
|
|
};
|
|
}
|
|
|
|
async reviewSrsItem(hashedUserId, payload = {}) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const courseId = this._clampInteger(payload?.courseId, { min: 1, max: 1_000_000, fallback: 0 });
|
|
if (!courseId) {
|
|
const err = new Error('Missing course id');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
if (course.ownerUserId !== user.id && !course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const learning = this._sanitizeShortString(payload?.learning, 1200);
|
|
const reference = this._sanitizeShortString(payload?.reference, 1200);
|
|
if (!learning || !reference) {
|
|
const err = new Error('Missing SRS item text');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const lessonId = payload?.lessonId == null
|
|
? null
|
|
: this._clampInteger(payload.lessonId, { min: 1, max: 1_000_000, fallback: 0 }) || null;
|
|
const direction = String(payload?.direction || 'BOTH').toUpperCase().slice(0, 8);
|
|
const itemKey = this._sanitizeShortString(payload?.itemKey, 80)
|
|
|| this._buildSrsItemKey({ courseId, lessonId, learning, reference, direction });
|
|
|
|
const [item] = await VocabSrsItem.findOrCreate({
|
|
where: {
|
|
userId: user.id,
|
|
itemKey
|
|
},
|
|
defaults: {
|
|
userId: user.id,
|
|
courseId,
|
|
lessonId,
|
|
itemKey,
|
|
learning,
|
|
reference,
|
|
direction,
|
|
nextDueAt: new Date()
|
|
}
|
|
});
|
|
|
|
if (
|
|
item.learning !== learning ||
|
|
item.reference !== reference ||
|
|
item.direction !== direction ||
|
|
item.lessonId !== lessonId
|
|
) {
|
|
item.learning = learning;
|
|
item.reference = reference;
|
|
item.direction = direction;
|
|
item.lessonId = lessonId;
|
|
}
|
|
|
|
const correct = Boolean(payload?.correct);
|
|
const schedule = this._calculateSrsSchedule(item, {
|
|
correct,
|
|
rating: payload?.rating
|
|
});
|
|
|
|
item.stage = schedule.stage;
|
|
item.intervalDays = schedule.intervalDays;
|
|
item.lastReviewedAt = new Date();
|
|
item.nextDueAt = schedule.nextDueAt;
|
|
if (correct) {
|
|
item.correctCount += 1;
|
|
} else {
|
|
item.wrongCount += 1;
|
|
item.lapseCount += schedule.lapseDelta;
|
|
}
|
|
await item.save();
|
|
|
|
return {
|
|
itemKey: item.itemKey,
|
|
correct,
|
|
stage: item.stage,
|
|
intervalDays: item.intervalDays,
|
|
lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt),
|
|
nextDueAt: this._normalizeIsoDate(item.nextDueAt),
|
|
correctCount: item.correctCount,
|
|
wrongCount: item.wrongCount,
|
|
lapseCount: item.lapseCount
|
|
};
|
|
}
|
|
|
|
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const access = await this._getLanguageAccess(user.id, languageId);
|
|
|
|
const query = typeof q === 'string' ? q.trim() : '';
|
|
// Abwärtskompatibel: falls alte Parameter genutzt werden, zusammenfassen
|
|
const learningTerm = typeof learning === 'string' ? learning.trim() : '';
|
|
const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : '';
|
|
const effective = query || learningTerm || motherTerm;
|
|
|
|
if (!effective) {
|
|
const err = new Error('Missing search term');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const like = `%${effective}%`;
|
|
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
cl.id,
|
|
c.id AS "chapterId",
|
|
c.title AS "chapterTitle",
|
|
l1.text AS "learning",
|
|
l2.text AS "motherTongue"
|
|
FROM community.vocab_chapter_lexeme cl
|
|
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
|
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
|
WHERE c.language_id = :languageId
|
|
AND (l1.text ILIKE :like OR l2.text ILIKE :like)
|
|
ORDER BY l2.text ASC, l1.text ASC, c.title ASC
|
|
LIMIT 200
|
|
`,
|
|
{
|
|
replacements: {
|
|
languageId: access.id,
|
|
like,
|
|
},
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
|
|
return { languageId: access.id, results: rows };
|
|
}
|
|
|
|
/**
|
|
* Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert.
|
|
* Ein Suchbegriff durchsucht Lern- und Referenzspalte (Teilstrings, ILIKE).
|
|
*/
|
|
async getLanguageDictionary(hashedUserId, languageId, query = {}) {
|
|
const { q, page: pageParam, pageSize: pageSizeParam } = query;
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const access = await this._getLanguageAccess(user.id, languageId);
|
|
const term = typeof q === 'string' ? q.trim() : '';
|
|
const like = term ? `%${term}%` : null;
|
|
const { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam });
|
|
|
|
const baseReplacements = like ? { languageId: access.id, like } : { languageId: access.id };
|
|
|
|
const countRows = await sequelize.query(
|
|
`
|
|
SELECT COUNT(*)::integer AS n
|
|
FROM community.vocab_chapter_lexeme cl
|
|
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
|
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
|
WHERE c.language_id = :languageId
|
|
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
|
|
`,
|
|
{
|
|
replacements: baseReplacements,
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
const total = countRows[0]?.n ?? 0;
|
|
const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize);
|
|
const effectivePage = Math.min(Math.max(1, page), totalPages);
|
|
const offset = (effectivePage - 1) * pageSize;
|
|
|
|
let rows = [];
|
|
if (total > 0) {
|
|
rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
cl.id,
|
|
c.id AS "chapterId",
|
|
c.title AS "chapterTitle",
|
|
l1.text AS "learning",
|
|
l2.text AS "reference"
|
|
FROM community.vocab_chapter_lexeme cl
|
|
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
|
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
|
WHERE c.language_id = :languageId
|
|
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
|
|
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
|
|
LIMIT :limit OFFSET :offset
|
|
`,
|
|
{
|
|
replacements: { ...baseReplacements, limit: pageSize, offset },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
}
|
|
);
|
|
}
|
|
|
|
return {
|
|
languageId: access.id,
|
|
results: rows,
|
|
total,
|
|
page: effectivePage,
|
|
pageSize,
|
|
totalPages,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Wörterbuch: aus abgeschlossenen Kurslektionen extrahierte Paare, optional gefiltert (Teilstring in beiden Spalten).
|
|
*/
|
|
async getCourseDictionary(hashedUserId, courseId, query = {}) {
|
|
const { q, page: pageParam, pageSize: pageSizeParam } = query;
|
|
const pool = await this.getCompletedLessonVocabPool(hashedUserId, courseId);
|
|
const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
|
|
let vocabs = pool.vocabs || [];
|
|
if (term) {
|
|
vocabs = vocabs.filter((entry) => {
|
|
const l = String(entry.learning || '').toLowerCase();
|
|
const r = String(entry.reference || '').toLowerCase();
|
|
return l.includes(term) || r.includes(term);
|
|
});
|
|
}
|
|
vocabs.sort((a, b) => {
|
|
const refCmp = String(a.reference || '').localeCompare(String(b.reference || ''), undefined, { sensitivity: 'base' });
|
|
if (refCmp !== 0) return refCmp;
|
|
return String(a.learning || '').localeCompare(String(b.learning || ''), undefined, { sensitivity: 'base' });
|
|
});
|
|
const { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam });
|
|
const total = vocabs.length;
|
|
const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize);
|
|
const effectivePage = Math.min(Math.max(1, page), totalPages);
|
|
const offset = (effectivePage - 1) * pageSize;
|
|
const paged = vocabs.slice(offset, offset + pageSize);
|
|
return {
|
|
courseId: pool.courseId,
|
|
results: paged,
|
|
total,
|
|
page: effectivePage,
|
|
pageSize,
|
|
totalPages,
|
|
};
|
|
}
|
|
|
|
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const ch = await this._getChapterAccess(user.id, chapterId);
|
|
if (!ch.isOwner) {
|
|
const err = new Error('Only owner can add vocab');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const learningText = typeof learning === 'string' ? learning.trim() : '';
|
|
const referenceText = typeof reference === 'string' ? reference.trim() : '';
|
|
if (!learningText || !referenceText) {
|
|
const err = new Error('Invalid vocab');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const learningNorm = this._normalizeLexeme(learningText);
|
|
const referenceNorm = this._normalizeLexeme(referenceText);
|
|
|
|
// Transaktion: Lexeme upserten + Zuordnung setzen
|
|
return await sequelize.transaction(async (t) => {
|
|
const [learningLex] = await sequelize.query(
|
|
`
|
|
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
|
|
VALUES (:languageId, :text, :normalized, :userId)
|
|
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
|
|
RETURNING id
|
|
`,
|
|
{
|
|
replacements: { languageId: ch.languageId, text: learningText, normalized: learningNorm, userId: user.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
transaction: t,
|
|
}
|
|
);
|
|
|
|
const [referenceLex] = await sequelize.query(
|
|
`
|
|
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
|
|
VALUES (:languageId, :text, :normalized, :userId)
|
|
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
|
|
RETURNING id
|
|
`,
|
|
{
|
|
replacements: { languageId: ch.languageId, text: referenceText, normalized: referenceNorm, userId: user.id },
|
|
type: sequelize.QueryTypes.SELECT,
|
|
transaction: t,
|
|
}
|
|
);
|
|
|
|
const [mapping] = await sequelize.query(
|
|
`
|
|
INSERT INTO community.vocab_chapter_lexeme (chapter_id, learning_lexeme_id, reference_lexeme_id, created_by_user_id)
|
|
VALUES (:chapterId, :learningId, :referenceId, :userId)
|
|
ON CONFLICT (chapter_id, learning_lexeme_id, reference_lexeme_id) DO NOTHING
|
|
RETURNING id
|
|
`,
|
|
{
|
|
replacements: {
|
|
chapterId: ch.id,
|
|
learningId: learningLex.id,
|
|
referenceId: referenceLex.id,
|
|
userId: user.id,
|
|
},
|
|
type: sequelize.QueryTypes.SELECT,
|
|
transaction: t,
|
|
}
|
|
);
|
|
|
|
return { created: Boolean(mapping?.id) };
|
|
});
|
|
}
|
|
|
|
// ========== COURSE METHODS ==========
|
|
|
|
async createCourse(hashedUserId, { title, description, languageId, nativeLanguageId, difficultyLevel = 1, isPublic = false }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
|
|
// Prüfe Zugriff auf Sprache
|
|
await this._getLanguageAccess(user.id, languageId);
|
|
|
|
const shareCode = isPublic ? crypto.randomBytes(8).toString('hex') : null;
|
|
|
|
const course = await VocabCourse.create({
|
|
ownerUserId: user.id,
|
|
title,
|
|
description,
|
|
languageId: Number(languageId),
|
|
nativeLanguageId: nativeLanguageId ? Number(nativeLanguageId) : null,
|
|
difficultyLevel: Number(difficultyLevel) || 1,
|
|
isPublic: Boolean(isPublic),
|
|
shareCode
|
|
});
|
|
|
|
return course.get({ plain: true });
|
|
}
|
|
|
|
async getCourses(hashedUserId, { includePublic = true, includeOwn = true, languageId, nativeLanguageId, search } = {}) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
|
|
// Konvertiere String-Parameter zu Booleans
|
|
const includePublicBool = includePublic === 'true' || includePublic === true;
|
|
const includeOwnBool = includeOwn === 'true' || includeOwn === true;
|
|
|
|
const where = {};
|
|
const andConditions = [];
|
|
|
|
// Zugriffsbedingungen
|
|
if (includeOwnBool && includePublicBool) {
|
|
andConditions.push({
|
|
[Op.or]: [
|
|
{ ownerUserId: user.id },
|
|
{ isPublic: true }
|
|
]
|
|
});
|
|
} else if (includeOwnBool) {
|
|
where.ownerUserId = user.id;
|
|
} else if (includePublicBool) {
|
|
where.isPublic = true;
|
|
}
|
|
|
|
// Filter nach Zielsprache (die zu lernende Sprache)
|
|
if (languageId) {
|
|
where.languageId = Number(languageId);
|
|
}
|
|
|
|
// Filter nach Muttersprache (die Sprache des Lerners)
|
|
// Wenn nativeLanguageId nicht gesetzt ist (undefined), zeige alle Kurse (kein Filter)
|
|
// Wenn nativeLanguageId === null oder 'null' (aus Frontend), zeige alle Kurse (kein Filter)
|
|
// Wenn nativeLanguageId eine Zahl ist, zeige nur Kurse für diese Muttersprache
|
|
if (nativeLanguageId !== undefined && nativeLanguageId !== null && nativeLanguageId !== 'null') {
|
|
where.nativeLanguageId = Number(nativeLanguageId);
|
|
}
|
|
// Wenn nativeLanguageId null/undefined/'null' ist, wird kein Filter angewendet = alle Kurse
|
|
|
|
// Suche nach Titel oder Beschreibung
|
|
if (search && search.trim()) {
|
|
const searchTerm = `%${search.trim()}%`;
|
|
andConditions.push({
|
|
[Op.or]: [
|
|
{ title: { [Op.iLike]: searchTerm } },
|
|
{ description: { [Op.iLike]: searchTerm } }
|
|
]
|
|
});
|
|
}
|
|
|
|
// Kombiniere alle AND-Bedingungen
|
|
// Wenn sowohl andConditions als auch direkte where-Eigenschaften existieren,
|
|
// müssen sie kombiniert werden
|
|
// WICHTIG: directWhereProps muss NACH dem Setzen aller direkten Eigenschaften berechnet werden
|
|
const directWhereProps = Object.keys(where).filter(key => {
|
|
// Filtere Op.and und Op.or heraus (sind Symbol-Keys)
|
|
return key !== Op.and && key !== Op.or && typeof key === 'string';
|
|
});
|
|
|
|
if (andConditions.length > 0) {
|
|
// Wenn where bereits direkte Eigenschaften hat, füge sie zu andConditions hinzu
|
|
if (directWhereProps.length > 0) {
|
|
const directWhere = {};
|
|
for (const key of directWhereProps) {
|
|
directWhere[key] = where[key];
|
|
delete where[key];
|
|
}
|
|
andConditions.push(directWhere);
|
|
}
|
|
// Entferne leere Objekte aus andConditions
|
|
const filteredConditions = andConditions.filter(cond => {
|
|
return cond && typeof cond === 'object' && Object.keys(cond).length > 0;
|
|
});
|
|
// Setze andConditions nur, wenn sie nicht leer sind
|
|
if (filteredConditions.length > 0) {
|
|
where[Op.and] = filteredConditions;
|
|
}
|
|
}
|
|
// Wenn nur direkte Eigenschaften existieren (andConditions.length === 0),
|
|
// bleiben sie in where (nichts zu tun, sie sind bereits dort)
|
|
|
|
const courses = await VocabCourse.findAll({
|
|
where,
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
|
|
const coursesData = courses.map(c => c.get({ plain: true }));
|
|
await this._attachLanguageNamesToCourseRows(coursesData);
|
|
|
|
return coursesData;
|
|
}
|
|
|
|
async getCourseByShareCode(hashedUserId, shareCode) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
|
|
|
|
if (!code || code.length < 6 || code.length > 128) {
|
|
const err = new Error('Invalid share code');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const course = await VocabCourse.findOne({
|
|
where: { shareCode: code }
|
|
});
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff (öffentlich oder Besitzer)
|
|
if (course.ownerUserId !== user.id && !course.isPublic) {
|
|
const err = new Error('Course is not public');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
return course.get({ plain: true });
|
|
}
|
|
|
|
async getCourse(hashedUserId, courseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId, {
|
|
include: [
|
|
{
|
|
model: VocabCourseLesson,
|
|
as: 'lessons',
|
|
order: [['lessonNumber', 'ASC']]
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (course.ownerUserId !== user.id && !course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const courseData = course.get({ plain: true });
|
|
courseData.lessons = courseData.lessons || [];
|
|
|
|
// Sortiere Lektionen nach Woche, Tag, dann Nummer
|
|
courseData.lessons.sort((a, b) => {
|
|
if (a.weekNumber !== b.weekNumber) {
|
|
return (a.weekNumber || 999) - (b.weekNumber || 999);
|
|
}
|
|
if (a.dayNumber !== b.dayNumber) {
|
|
return (a.dayNumber || 999) - (b.dayNumber || 999);
|
|
}
|
|
return a.lessonNumber - b.lessonNumber;
|
|
});
|
|
|
|
courseData.lessons = courseData.lessons.map((lesson) => ({
|
|
...lesson,
|
|
pedagogy: this._buildLessonPedagogy(lesson)
|
|
}));
|
|
|
|
await this._attachLanguageNamesToCourseRows([courseData]);
|
|
|
|
return courseData;
|
|
}
|
|
|
|
/** Admin/Support: Kurs inkl. Lektionen ohne Sichtbarkeitsprüfung (nur serverseitig für Staff-Routen). */
|
|
async adminGetCourseWithLessonsForStaff(courseId) {
|
|
const course = await VocabCourse.findByPk(Number(courseId), {
|
|
include: [
|
|
{
|
|
model: VocabCourseLesson,
|
|
as: 'lessons',
|
|
order: [['lessonNumber', 'ASC']]
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
const courseData = course.get({ plain: true });
|
|
courseData.lessons = courseData.lessons || [];
|
|
|
|
courseData.lessons.sort((a, b) => {
|
|
if (a.weekNumber !== b.weekNumber) {
|
|
return (a.weekNumber || 999) - (b.weekNumber || 999);
|
|
}
|
|
if (a.dayNumber !== b.dayNumber) {
|
|
return (a.dayNumber || 999) - (b.dayNumber || 999);
|
|
}
|
|
return a.lessonNumber - b.lessonNumber;
|
|
});
|
|
|
|
courseData.lessons = courseData.lessons.map((lesson) => ({
|
|
...lesson,
|
|
pedagogy: this._buildLessonPedagogy(lesson)
|
|
}));
|
|
|
|
return courseData;
|
|
}
|
|
|
|
async updateCourse(hashedUserId, courseId, { title, description, languageId, nativeLanguageId, difficultyLevel, isPublic }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can update the course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const updates = {};
|
|
if (title !== undefined) updates.title = title;
|
|
if (description !== undefined) updates.description = description;
|
|
if (languageId !== undefined) updates.languageId = Number(languageId);
|
|
if (nativeLanguageId !== undefined) updates.nativeLanguageId = nativeLanguageId ? Number(nativeLanguageId) : null;
|
|
if (difficultyLevel !== undefined) updates.difficultyLevel = Number(difficultyLevel);
|
|
if (isPublic !== undefined) {
|
|
updates.isPublic = Boolean(isPublic);
|
|
// Generiere Share-Code wenn Kurs öffentlich wird
|
|
if (isPublic && !course.shareCode) {
|
|
updates.shareCode = crypto.randomBytes(8).toString('hex');
|
|
} else if (!isPublic) {
|
|
updates.shareCode = null;
|
|
}
|
|
}
|
|
|
|
await course.update(updates);
|
|
return course.get({ plain: true });
|
|
}
|
|
|
|
async deleteCourse(hashedUserId, courseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can delete the course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
await course.destroy();
|
|
return { success: true };
|
|
}
|
|
|
|
_seededShuffle(items, seed) {
|
|
const arr = items.slice();
|
|
let t = (Number(seed) >>> 0) ^ 0x6a09e667;
|
|
const rnd = () => {
|
|
t ^= t << 13;
|
|
t ^= t >>> 17;
|
|
t ^= t << 5;
|
|
return (t >>> 0) / 4294967296;
|
|
};
|
|
for (let i = arr.length - 1; i > 0; i -= 1) {
|
|
const j = Math.floor(rnd() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
_buildDeterministicChapterLexemeMcOptions(correct, distractorSource, seed) {
|
|
const norm = (s) => this._normalizeTextAnswer(s);
|
|
const nc = norm(correct);
|
|
const filtered = distractorSource.filter((t) => norm(t) !== nc && String(t || '').trim());
|
|
const shuffled = this._seededShuffle(filtered, (seed ^ 0x9e3779b9) >>> 0);
|
|
const picks = [];
|
|
for (const p of shuffled) {
|
|
if (picks.length >= 3) {
|
|
break;
|
|
}
|
|
picks.push(p);
|
|
}
|
|
let pad = 1;
|
|
while (picks.length < 3) {
|
|
picks.push(`(${pad})`);
|
|
pad += 1;
|
|
}
|
|
const ordered = this._seededShuffle([correct, ...picks], seed >>> 0);
|
|
const correctAnswer = ordered.findIndex((o) => String(o) === String(correct));
|
|
return { options: ordered, correctAnswer: correctAnswer >= 0 ? correctAnswer : 0 };
|
|
}
|
|
|
|
_lexemePairCoveredByMultipleChoice(exerciseList, learning, reference) {
|
|
const nl = this._normalizeTextAnswer(learning);
|
|
const nr = this._normalizeTextAnswer(reference);
|
|
if (!nl || !nr) {
|
|
return false;
|
|
}
|
|
for (const ex of exerciseList) {
|
|
if (Number(ex.exerciseTypeId) !== 2) {
|
|
continue;
|
|
}
|
|
const qd = typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData;
|
|
if (!qd || qd.type !== 'multiple_choice') {
|
|
continue;
|
|
}
|
|
const prompt = this._normalizeTextAnswer(qd.question || qd.text || '');
|
|
if (!prompt.includes(nl)) {
|
|
continue;
|
|
}
|
|
const ad = typeof ex.answerData === 'string' ? JSON.parse(ex.answerData) : ex.answerData;
|
|
const options = Array.isArray(qd.options) ? qd.options : [];
|
|
let idx = ad?.correctAnswer;
|
|
if (Array.isArray(idx)) {
|
|
idx = idx[0];
|
|
}
|
|
if (idx === undefined && ad?.correct !== undefined) {
|
|
idx = Array.isArray(ad.correct) ? ad.correct[0] : ad.correct;
|
|
}
|
|
if (idx === undefined || options[Number(idx)] === undefined) {
|
|
continue;
|
|
}
|
|
const correctOpt = this._normalizeTextAnswer(options[Number(idx)]);
|
|
if (correctOpt === nr) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
_buildSyntheticLexemeMcExercisePlain(lessonId, row, exerciseNumber) {
|
|
const learning = String(row.learning || '').trim();
|
|
const reference = String(row.reference || '').trim();
|
|
if (!learning || !reference) {
|
|
return null;
|
|
}
|
|
const seed = (Number(row.id) * 100003 + Number(lessonId)) >>> 0;
|
|
const { options, correctAnswer } = this._buildDeterministicChapterLexemeMcOptions(
|
|
reference,
|
|
row.allReferences || [],
|
|
seed
|
|
);
|
|
const questionData = {
|
|
type: 'multiple_choice',
|
|
question: `Was bedeutet „${learning}“?`,
|
|
options,
|
|
randomizeDistractors: false
|
|
};
|
|
const answerData = {
|
|
type: 'multiple_choice',
|
|
correctAnswer
|
|
};
|
|
return {
|
|
id: `syn-${lessonId}-${row.id}-l2r`,
|
|
lessonId,
|
|
exerciseTypeId: 2,
|
|
exerciseType: { id: 2, name: 'multiple_choice' },
|
|
exerciseNumber,
|
|
title: `Kapitel-Vokabel: ${learning}`,
|
|
instruction: 'Wähle die passende Übersetzung.',
|
|
questionData,
|
|
answerData,
|
|
explanation: null,
|
|
createdByUserId: 0
|
|
};
|
|
}
|
|
|
|
async _fetchChapterLexemeRowsForMc(chapterId) {
|
|
const id = Number.parseInt(chapterId, 10);
|
|
if (!Number.isFinite(id)) {
|
|
return [];
|
|
}
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
cl.id,
|
|
l1.text AS learning,
|
|
l2.text AS reference
|
|
FROM community.vocab_chapter_lexeme cl
|
|
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
|
|
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
|
|
WHERE cl.chapter_id = :chapterId
|
|
ORDER BY cl.id ASC
|
|
`,
|
|
{
|
|
replacements: { chapterId: id },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
async _mergeSyntheticChapterLexemeMcExercises(plainLesson, grammarExercises) {
|
|
const list = Array.isArray(grammarExercises) ? [...grammarExercises] : [];
|
|
if (!plainLesson?.chapterId) {
|
|
return list;
|
|
}
|
|
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') {
|
|
return list;
|
|
}
|
|
const rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId);
|
|
if (!rows.length) {
|
|
return list;
|
|
}
|
|
const allReferences = rows.map((r) => r.reference).filter(Boolean);
|
|
let maxNum = list.reduce((m, ex) => Math.max(m, Number(ex.exerciseNumber) || 0), 0);
|
|
const augmentedRows = rows.map((r) => ({ ...r, allReferences }));
|
|
for (const row of augmentedRows) {
|
|
if (this._lexemePairCoveredByMultipleChoice(list, row.learning, row.reference)) {
|
|
continue;
|
|
}
|
|
maxNum += 1;
|
|
const ex = this._buildSyntheticLexemeMcExercisePlain(plainLesson.id, row, maxNum);
|
|
if (ex) {
|
|
list.push(ex);
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
|
|
async _checkSyntheticLexemeMcAnswer(user, lessonId, chapterLexemeId, userAnswer) {
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
if (!lesson) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: lesson.courseId }
|
|
});
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
if (!lesson.chapterId) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
const rows = await this._fetchChapterLexemeRowsForMc(lesson.chapterId);
|
|
const row = rows.find((r) => Number(r.id) === Number(chapterLexemeId));
|
|
if (!row) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
const learning = String(row.learning || '').trim();
|
|
const reference = String(row.reference || '').trim();
|
|
if (!learning || !reference) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
const allReferences = rows.map((r) => r.reference).filter(Boolean);
|
|
const seed = (Number(row.id) * 100003 + Number(lessonId)) >>> 0;
|
|
const { options, correctAnswer } = this._buildDeterministicChapterLexemeMcOptions(reference, allReferences, seed);
|
|
const questionData = {
|
|
type: 'multiple_choice',
|
|
question: `Was bedeutet „${learning}“?`,
|
|
options,
|
|
randomizeDistractors: false
|
|
};
|
|
const answerData = {
|
|
type: 'multiple_choice',
|
|
correctAnswer
|
|
};
|
|
const isCorrect = this._checkAnswer(answerData, questionData, userAnswer, 2);
|
|
const correctIdx = Number(correctAnswer);
|
|
const correctAnswerText = options[correctIdx];
|
|
const alternatives = options.filter((_, idx) => idx !== correctIdx);
|
|
|
|
return {
|
|
correct: isCorrect,
|
|
correctAnswer: correctAnswerText || null,
|
|
alternatives,
|
|
explanation: null,
|
|
progress: {
|
|
attempts: 1,
|
|
correctAttempts: isCorrect ? 1 : 0,
|
|
lastAttemptAt: new Date(),
|
|
completed: Boolean(isCorrect),
|
|
completedAt: isCorrect ? new Date() : null
|
|
}
|
|
};
|
|
}
|
|
|
|
async getLesson(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [
|
|
{
|
|
model: VocabCourse,
|
|
as: 'course'
|
|
},
|
|
{
|
|
model: VocabGrammarExercise,
|
|
as: 'grammarExercises',
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
}
|
|
],
|
|
required: false,
|
|
separate: true,
|
|
order: [['exerciseNumber', 'ASC']]
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const progress = await VocabCourseProgress.findOne({
|
|
where: {
|
|
userId: user.id,
|
|
lessonId: lesson.id
|
|
}
|
|
});
|
|
|
|
const plainLesson = lesson.get({ plain: true });
|
|
|
|
// Lade Vokabeln aus vorherigen Lektionen (für Wiederholung UND für gemischten Vokabeltrainer)
|
|
if (plainLesson.lessonNumber > 1) {
|
|
plainLesson.previousLessonExercises = await this._getReviewVocabExercises(plainLesson.courseId, plainLesson.lessonNumber);
|
|
}
|
|
// Bei Wiederholungslektionen: Auch Lektions-Liste für Anzeige
|
|
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') {
|
|
plainLesson.reviewLessons = await this._getReviewLessons(plainLesson.courseId, plainLesson.lessonNumber);
|
|
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
|
|
}
|
|
|
|
plainLesson.grammarExercises = await this._mergeSyntheticChapterLexemeMcExercises(
|
|
plainLesson,
|
|
plainLesson.grammarExercises || []
|
|
);
|
|
|
|
const suppressLessonReviewDue = await this._courseHasDueSrsItems(user.id, plainLesson.courseId);
|
|
|
|
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
|
|
plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson);
|
|
plainLesson.progress = this._serializeLessonProgress(progress, plainLesson, { suppressLessonReviewDue });
|
|
return plainLesson;
|
|
}
|
|
|
|
async sendLessonAssistantMessage(hashedUserId, lessonId, payload = {}) {
|
|
const requestId = `assist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const log = (...args) => console.log(`[LLM ${requestId}]`, ...args);
|
|
const startedAt = Date.now();
|
|
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await this.getLesson(hashedUserId, lessonId);
|
|
const config = await this._getUserLlmConfig(user.id);
|
|
|
|
log('start', {
|
|
userId: user.id,
|
|
lessonId,
|
|
enabled: config.enabled,
|
|
configured: config.configured,
|
|
hasKey: config.hasKey,
|
|
baseUrl: config.baseUrl || '(default openai)',
|
|
model: config.model
|
|
});
|
|
|
|
if (!config.enabled) {
|
|
log('aborted: assistant disabled in user settings');
|
|
const err = new Error('Der Sprachassistent ist in deinen Einstellungen derzeit deaktiviert.');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
if (!config.configured) {
|
|
log('aborted: assistant not 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) {
|
|
log('aborted: empty 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 configuredTimeout = Number(process.env.LLM_ASSISTANT_TIMEOUT_MS);
|
|
const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout >= 30000
|
|
? configuredTimeout
|
|
: 300000;
|
|
const timeout = setTimeout(() => {
|
|
log('timeout reached, aborting fetch', { timeoutMs });
|
|
controller.abort();
|
|
}, timeoutMs);
|
|
|
|
const temperatureByMode = {
|
|
explain: 0.4,
|
|
practice: 0.5,
|
|
correct: 0.1
|
|
};
|
|
const temperature = Number.isFinite(temperatureByMode[mode]) ? temperatureByMode[mode] : 0.5;
|
|
|
|
log('request', {
|
|
endpoint,
|
|
mode,
|
|
temperature,
|
|
timeoutMs,
|
|
historyMessages: history.length,
|
|
messagePreview: message.slice(0, 120)
|
|
});
|
|
|
|
let response;
|
|
let fetchStartedAt = Date.now();
|
|
try {
|
|
response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers,
|
|
signal: controller.signal,
|
|
body: JSON.stringify({
|
|
model: config.model,
|
|
temperature,
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content: this._buildLessonAssistantSystemPrompt(lesson, mode)
|
|
},
|
|
...history,
|
|
{
|
|
role: 'user',
|
|
content: message
|
|
}
|
|
]
|
|
})
|
|
});
|
|
log('response received', {
|
|
status: response.status,
|
|
latencyMs: Date.now() - fetchStartedAt
|
|
});
|
|
} catch (error) {
|
|
log('fetch failed', {
|
|
name: error?.name,
|
|
message: error?.message,
|
|
cause: error?.cause?.code || error?.cause?.errno || null,
|
|
latencyMs: Date.now() - fetchStartedAt
|
|
});
|
|
const err = new Error(
|
|
error?.name === 'AbortError'
|
|
? 'Der Sprachassistent hat das Antwort-Zeitlimit überschritten.'
|
|
: 'Der Sprachassistent konnte nicht erreicht werden.'
|
|
);
|
|
err.status = 502;
|
|
throw err;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
|
|
let responseData = null;
|
|
try {
|
|
responseData = await response.json();
|
|
} catch (parseError) {
|
|
log('failed to parse response JSON', { message: parseError?.message });
|
|
responseData = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
log('upstream returned non-ok', {
|
|
status: response.status,
|
|
body: responseData ? JSON.stringify(responseData).slice(0, 500) : null
|
|
});
|
|
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) {
|
|
log('empty reply from upstream', {
|
|
responsePreview: responseData ? JSON.stringify(responseData).slice(0, 500) : null
|
|
});
|
|
const err = new Error('Der Sprachassistent hat keine verwertbare Antwort geliefert.');
|
|
err.status = 502;
|
|
throw err;
|
|
}
|
|
|
|
log('done', {
|
|
totalMs: Date.now() - startedAt,
|
|
model: responseData?.model || config.model,
|
|
replyLength: reply.length
|
|
});
|
|
|
|
return {
|
|
reply,
|
|
model: responseData?.model || config.model,
|
|
mode
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
|
|
*/
|
|
async _getReviewLessons(courseId, currentLessonNumber) {
|
|
const lessons = await VocabCourseLesson.findAll({
|
|
where: {
|
|
courseId: courseId,
|
|
lessonNumber: {
|
|
[Op.lt]: currentLessonNumber // Nur Lektionen mit kleinerer Nummer
|
|
},
|
|
lessonType: {
|
|
[Op.notIn]: ['review', 'vocab_review'] // Keine anderen Wiederholungslektionen
|
|
}
|
|
},
|
|
order: [['lessonNumber', 'ASC']],
|
|
attributes: ['id', 'lessonNumber', 'title']
|
|
});
|
|
return lessons.map(l => l.get({ plain: true }));
|
|
}
|
|
|
|
/**
|
|
* Sammelt alle Grammatik-Übungen aus vorherigen Lektionen für Wiederholungslektionen
|
|
*/
|
|
async _getReviewVocabExercises(courseId, currentLessonNumber) {
|
|
const previousLessons = await VocabCourseLesson.findAll({
|
|
where: {
|
|
courseId: courseId,
|
|
lessonNumber: {
|
|
[Op.lt]: currentLessonNumber
|
|
},
|
|
lessonType: {
|
|
[Op.notIn]: ['review', 'vocab_review']
|
|
}
|
|
},
|
|
attributes: ['id']
|
|
});
|
|
|
|
if (previousLessons.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const lessonIds = previousLessons.map(l => l.id);
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: {
|
|
lessonId: {
|
|
[Op.in]: lessonIds
|
|
}
|
|
},
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
},
|
|
{
|
|
model: VocabCourseLesson,
|
|
as: 'lesson',
|
|
attributes: ['id', 'lessonNumber', 'title']
|
|
}
|
|
],
|
|
order: [
|
|
[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC'],
|
|
['exerciseNumber', 'ASC']
|
|
]
|
|
});
|
|
|
|
return exercises.map(e => e.get({ plain: true }));
|
|
}
|
|
|
|
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can add lessons');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe, ob Kapitel zur gleichen Sprache gehört (nur wenn chapterId angegeben)
|
|
if (chapterId) {
|
|
const [chapter] = await sequelize.query(
|
|
`SELECT language_id FROM community.vocab_chapter WHERE id = :chapterId`,
|
|
{
|
|
replacements: { chapterId: Number(chapterId) },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
|
|
if (!chapter || chapter.language_id !== course.languageId) {
|
|
const err = new Error('Chapter does not belong to the course language');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const lesson = await VocabCourseLesson.create({
|
|
courseId: course.id,
|
|
chapterId: chapterId ? Number(chapterId) : null,
|
|
lessonNumber: Number(lessonNumber),
|
|
title,
|
|
description,
|
|
weekNumber: weekNumber ? Number(weekNumber) : null,
|
|
dayNumber: dayNumber ? Number(dayNumber) : null,
|
|
lessonType: lessonType || 'vocab',
|
|
didacticMode: this._normalizeOptionalString(didacticMode),
|
|
phaseLabel: this._normalizeOptionalString(phaseLabel),
|
|
blockNumber: this._normalizeOptionalInteger(blockNumber),
|
|
difficultyWeight: this._normalizeOptionalInteger(difficultyWeight),
|
|
newUnitTarget: this._normalizeOptionalInteger(newUnitTarget),
|
|
reviewWeight: this._normalizeOptionalInteger(reviewWeight),
|
|
isIntensiveReview: isIntensiveReview !== undefined ? Boolean(isIntensiveReview) : false,
|
|
audioUrl: audioUrl || null,
|
|
culturalNotes: culturalNotes || null,
|
|
learningGoals: this._normalizeStringList(learningGoals),
|
|
corePatterns: this._normalizeCorePatternList(corePatterns),
|
|
grammarFocus: this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']),
|
|
speakingPrompts: this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']),
|
|
practicalTasks: this._normalizeStructuredList(practicalTasks, ['title', 'text']),
|
|
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
|
|
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
|
|
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
|
|
});
|
|
|
|
return lesson.get({ plain: true });
|
|
}
|
|
|
|
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, didacticMode, phaseLabel, blockNumber, difficultyWeight, newUnitTarget, reviewWeight, isIntensiveReview, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can update lessons');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const updates = {};
|
|
if (title !== undefined) updates.title = title;
|
|
if (description !== undefined) updates.description = description;
|
|
if (lessonNumber !== undefined) updates.lessonNumber = Number(lessonNumber);
|
|
if (weekNumber !== undefined) updates.weekNumber = weekNumber ? Number(weekNumber) : null;
|
|
if (dayNumber !== undefined) updates.dayNumber = dayNumber ? Number(dayNumber) : null;
|
|
if (lessonType !== undefined) updates.lessonType = lessonType;
|
|
if (didacticMode !== undefined) updates.didacticMode = this._normalizeOptionalString(didacticMode);
|
|
if (phaseLabel !== undefined) updates.phaseLabel = this._normalizeOptionalString(phaseLabel);
|
|
if (blockNumber !== undefined) updates.blockNumber = this._normalizeOptionalInteger(blockNumber);
|
|
if (difficultyWeight !== undefined) updates.difficultyWeight = this._normalizeOptionalInteger(difficultyWeight);
|
|
if (newUnitTarget !== undefined) updates.newUnitTarget = this._normalizeOptionalInteger(newUnitTarget);
|
|
if (reviewWeight !== undefined) updates.reviewWeight = this._normalizeOptionalInteger(reviewWeight);
|
|
if (isIntensiveReview !== undefined) updates.isIntensiveReview = Boolean(isIntensiveReview);
|
|
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
|
|
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
|
|
if (learningGoals !== undefined) updates.learningGoals = this._normalizeStringList(learningGoals);
|
|
if (corePatterns !== undefined) updates.corePatterns = this._normalizeCorePatternList(corePatterns);
|
|
if (grammarFocus !== undefined) updates.grammarFocus = this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']);
|
|
if (speakingPrompts !== undefined) updates.speakingPrompts = this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']);
|
|
if (practicalTasks !== undefined) updates.practicalTasks = this._normalizeStructuredList(practicalTasks, ['title', 'text']);
|
|
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
|
|
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
|
|
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
|
|
|
|
await lesson.update(updates);
|
|
return lesson.get({ plain: true });
|
|
}
|
|
|
|
async deleteLesson(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can delete lessons');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
await lesson.destroy();
|
|
return { success: true };
|
|
}
|
|
|
|
async enrollInCourse(hashedUserId, courseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const course = await VocabCourse.findByPk(courseId);
|
|
|
|
if (!course) {
|
|
const err = new Error('Course not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (course.ownerUserId !== user.id && !course.isPublic) {
|
|
const err = new Error('Course is not public');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const [enrollment, created] = await VocabCourseEnrollment.findOrCreate({
|
|
where: { userId: user.id, courseId: course.id },
|
|
defaults: { userId: user.id, courseId: course.id }
|
|
});
|
|
|
|
if (!created) {
|
|
const err = new Error('Already enrolled in this course');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
return enrollment.get({ plain: true });
|
|
}
|
|
|
|
async unenrollFromCourse(hashedUserId, courseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: Number(courseId) }
|
|
});
|
|
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
await enrollment.destroy();
|
|
return { success: true };
|
|
}
|
|
|
|
async getMyCourses(hashedUserId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
|
|
const enrollments = await VocabCourseEnrollment.findAll({
|
|
where: { userId: user.id },
|
|
include: [{ model: VocabCourse, as: 'course' }],
|
|
order: [['enrolledAt', 'DESC']]
|
|
});
|
|
|
|
return enrollments.map(e => ({
|
|
...e.course.get({ plain: true }),
|
|
enrolledAt: e.enrolledAt
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Kompakte Übersicht für das Start-Dashboard: eingeschriebene Kurse und „aktuelle“ Lektion
|
|
* (gleiche Logik wie VocabCourseView.currentLesson: erste unvollständige, sonst letzte).
|
|
*/
|
|
async getDashboardLearningSummary(hashedUserId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
|
|
const enrollments = await VocabCourseEnrollment.findAll({
|
|
where: { userId: user.id },
|
|
include: [
|
|
{
|
|
model: VocabCourse,
|
|
as: 'course',
|
|
required: true,
|
|
attributes: ['id', 'title']
|
|
}
|
|
],
|
|
order: [['enrolledAt', 'DESC']]
|
|
});
|
|
|
|
const courseById = new Map();
|
|
for (const e of enrollments) {
|
|
const c = e.course?.get({ plain: true });
|
|
if (!c?.id || courseById.has(c.id)) {
|
|
continue;
|
|
}
|
|
courseById.set(c.id, { id: c.id, title: c.title || '' });
|
|
}
|
|
|
|
const coursesMeta = [...courseById.values()];
|
|
if (coursesMeta.length === 0) {
|
|
return { courses: [] };
|
|
}
|
|
|
|
const courseIds = coursesMeta.map((c) => c.id);
|
|
|
|
const lessons = await VocabCourseLesson.findAll({
|
|
where: { courseId: { [Op.in]: courseIds } },
|
|
attributes: ['id', 'courseId', 'lessonNumber', 'title'],
|
|
order: [
|
|
['courseId', 'ASC'],
|
|
['lessonNumber', 'ASC']
|
|
]
|
|
});
|
|
|
|
const progressRows = await VocabCourseProgress.findAll({
|
|
where: { userId: user.id, courseId: { [Op.in]: courseIds } },
|
|
attributes: ['lessonId', 'completed']
|
|
});
|
|
|
|
const completedByLessonId = new Map();
|
|
for (const row of progressRows) {
|
|
const plain = row.get({ plain: true });
|
|
completedByLessonId.set(plain.lessonId, Boolean(plain.completed));
|
|
}
|
|
|
|
const lessonsByCourse = new Map();
|
|
for (const row of lessons) {
|
|
const plain = row.get({ plain: true });
|
|
const list = lessonsByCourse.get(plain.courseId) || [];
|
|
list.push(plain);
|
|
lessonsByCourse.set(plain.courseId, list);
|
|
}
|
|
|
|
const courses = [];
|
|
for (const meta of coursesMeta) {
|
|
const sorted = lessonsByCourse.get(meta.id) || [];
|
|
if (sorted.length === 0) {
|
|
courses.push({
|
|
courseId: meta.id,
|
|
title: meta.title,
|
|
currentLesson: null,
|
|
allLessonsCompleted: false
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let current = null;
|
|
for (const lesson of sorted) {
|
|
if (!completedByLessonId.get(lesson.id)) {
|
|
current = lesson;
|
|
break;
|
|
}
|
|
}
|
|
if (!current) {
|
|
current = sorted[sorted.length - 1];
|
|
}
|
|
|
|
const allLessonsCompleted = sorted.every((lesson) => completedByLessonId.get(lesson.id) === true);
|
|
|
|
courses.push({
|
|
courseId: meta.id,
|
|
title: meta.title,
|
|
currentLesson: {
|
|
id: current.id,
|
|
lessonNumber: current.lessonNumber,
|
|
title: current.title || ''
|
|
},
|
|
allLessonsCompleted
|
|
});
|
|
}
|
|
|
|
return { courses };
|
|
}
|
|
|
|
/**
|
|
* Kurse, in die der Nutzer (per Hash) eingeschrieben ist — jede courseId nur einmal,
|
|
* bei mehrfachen Einschreibungen zählt die jeweils neueste Zeile.
|
|
*/
|
|
async listEnrolledVocabCoursesForUser(targetHashedUserId) {
|
|
const user = await this._getUserByHashedId(targetHashedUserId);
|
|
|
|
const enrollments = await VocabCourseEnrollment.findAll({
|
|
where: { userId: user.id },
|
|
include: [{ model: VocabCourse, as: 'course', required: true }],
|
|
order: [['enrolledAt', 'DESC']]
|
|
});
|
|
|
|
const byCourseId = new Map();
|
|
for (const e of enrollments) {
|
|
const row = e.course;
|
|
if (!row) {
|
|
continue;
|
|
}
|
|
const plain = row.get({ plain: true });
|
|
if (byCourseId.has(plain.id)) {
|
|
continue;
|
|
}
|
|
byCourseId.set(plain.id, {
|
|
...plain,
|
|
enrolledAt: e.enrolledAt
|
|
});
|
|
}
|
|
|
|
const coursesData = [...byCourseId.values()];
|
|
await this._attachLanguageNamesToCourseRows(coursesData);
|
|
return coursesData;
|
|
}
|
|
|
|
async getCourseProgress(hashedUserId, courseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
|
|
// Prüfe Einschreibung
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: Number(courseId) }
|
|
});
|
|
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const progress = await VocabCourseProgress.findAll({
|
|
where: { userId: user.id, courseId: Number(courseId) },
|
|
include: [{ model: VocabCourseLesson, as: 'lesson' }],
|
|
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']]
|
|
});
|
|
|
|
const suppressLessonReviewDue = await this._courseHasDueSrsItems(user.id, courseId);
|
|
return progress.map((entry) => this._serializeLessonProgress(entry, entry.lesson, { suppressLessonReviewDue }));
|
|
}
|
|
|
|
async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes, lessonState }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Einschreibung
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: lesson.courseId }
|
|
});
|
|
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const lessonData = await VocabCourseLesson.findByPk(lesson.id);
|
|
const targetScore = lessonData.targetScorePercent || 80;
|
|
const actualScore = Number(score) || 0;
|
|
const hasReachedTarget = actualScore >= targetScore;
|
|
const sanitizedLessonState = lessonState === undefined ? undefined : this._sanitizeLessonState(lessonState);
|
|
const didSubmitResult = completed !== undefined || score !== undefined;
|
|
|
|
// Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true)
|
|
const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false);
|
|
|
|
const [progress, created] = await VocabCourseProgress.findOrCreate({
|
|
where: { userId: user.id, lessonId: lesson.id },
|
|
defaults: {
|
|
userId: user.id,
|
|
courseId: lesson.courseId,
|
|
lessonId: lesson.id,
|
|
completed: isCompleted,
|
|
score: actualScore,
|
|
lessonState: sanitizedLessonState || {},
|
|
lastAccessedAt: new Date()
|
|
}
|
|
});
|
|
|
|
if (!created) {
|
|
const updates = { lastAccessedAt: new Date() };
|
|
if (score !== undefined) {
|
|
updates.score = Math.max(progress.score, actualScore);
|
|
// Prüfe, ob Ziel jetzt erreicht wurde
|
|
if (updates.score >= targetScore && !progress.completed) {
|
|
if (!lessonData.requiresReview) {
|
|
updates.completed = true;
|
|
updates.completedAt = new Date();
|
|
}
|
|
}
|
|
}
|
|
if (completed !== undefined) {
|
|
updates.completed = Boolean(completed);
|
|
if (completed && !progress.completedAt) {
|
|
updates.completedAt = new Date();
|
|
}
|
|
}
|
|
if (sanitizedLessonState !== undefined) {
|
|
updates.lessonState = sanitizedLessonState;
|
|
}
|
|
const nextCompleted = updates.completed !== undefined ? Boolean(updates.completed) : Boolean(progress.completed);
|
|
const mergedLessonState = {
|
|
...this._sanitizeLessonState(progress.lessonState),
|
|
...(updates.lessonState || {})
|
|
};
|
|
updates.lessonState = this._applyScheduledReviewState(mergedLessonState, {
|
|
previousCompleted: Boolean(progress.completed),
|
|
nextCompleted,
|
|
shouldAdvanceReview: didSubmitResult && nextCompleted,
|
|
lessonData,
|
|
now: updates.completedAt || updates.lastAccessedAt || new Date()
|
|
});
|
|
await progress.update(updates);
|
|
} else if (isCompleted) {
|
|
progress.completed = true;
|
|
progress.completedAt = new Date();
|
|
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState || {}, {
|
|
previousCompleted: false,
|
|
nextCompleted: true,
|
|
shouldAdvanceReview: didSubmitResult,
|
|
lessonData,
|
|
now: progress.completedAt
|
|
});
|
|
await progress.save();
|
|
} else if (sanitizedLessonState !== undefined) {
|
|
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState, {
|
|
previousCompleted: false,
|
|
nextCompleted: false,
|
|
shouldAdvanceReview: false,
|
|
lessonData,
|
|
now: new Date()
|
|
});
|
|
await progress.save();
|
|
}
|
|
|
|
return this._serializeLessonProgress(progress, lessonData);
|
|
}
|
|
|
|
/**
|
|
* Löscht nur den Fortschritt zu einer Lektion (Zeile vocab_course_progress + zugehörige grammar-exercise-progress).
|
|
* Gesamtkurs / andere Lektionen bleiben unberührt.
|
|
*/
|
|
async _purgeLessonProgressForUser(userId, lessonId) {
|
|
const numericLessonId = Number(lessonId);
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: { lessonId: numericLessonId },
|
|
attributes: ['id']
|
|
});
|
|
const exerciseIds = exercises.map((e) => e.id);
|
|
let deletedExerciseProgressRows = 0;
|
|
if (exerciseIds.length > 0) {
|
|
deletedExerciseProgressRows = await VocabGrammarExerciseProgress.destroy({
|
|
where: { userId, exerciseId: { [Op.in]: exerciseIds } }
|
|
});
|
|
}
|
|
const deletedLessonProgressRows = await VocabCourseProgress.destroy({
|
|
where: { userId, lessonId: numericLessonId }
|
|
});
|
|
return {
|
|
success: true,
|
|
lessonId: numericLessonId,
|
|
deletedLessonProgressRows,
|
|
deletedExerciseProgressRows
|
|
};
|
|
}
|
|
|
|
/** Eingeloggter Nutzer setzt eigene Lektion zurück (nur bei Kurs-Einschreibung). */
|
|
async resetMyLessonProgress(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: lesson.courseId }
|
|
});
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
return this._purgeLessonProgressForUser(user.id, lesson.id);
|
|
}
|
|
|
|
/** Admin: Zielnutzer per Hash, ohne Einschreibungszwang (idempotentes Löschen). */
|
|
async adminResetLessonProgressForUser(targetHashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(targetHashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
return this._purgeLessonProgressForUser(user.id, lesson.id);
|
|
}
|
|
|
|
/**
|
|
* Admin: Alle Lektionen eines Kurses bis einschließlich lesson_number als abgeschlossen setzen
|
|
* (nur Zeilen, die noch nicht completed sind). Nur bei eingeschriebenem Nutzer.
|
|
*/
|
|
async adminMarkLessonsCompleteThrough(targetHashedUserId, courseId, throughLessonNumber) {
|
|
const user = await this._getUserByHashedId(targetHashedUserId);
|
|
const cid = Number(courseId);
|
|
const maxNum = Number(throughLessonNumber);
|
|
if (!Number.isFinite(cid) || cid < 1) {
|
|
const err = new Error('Invalid courseId');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
if (!Number.isFinite(maxNum) || maxNum < 1) {
|
|
const err = new Error('Invalid throughLessonNumber');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: cid }
|
|
});
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const lessons = await VocabCourseLesson.findAll({
|
|
where: { courseId: cid, lessonNumber: { [Op.lte]: maxNum } },
|
|
order: [['lessonNumber', 'ASC']]
|
|
});
|
|
|
|
const now = new Date();
|
|
const details = [];
|
|
|
|
for (const lesson of lessons) {
|
|
const lessonData = lesson.get({ plain: true });
|
|
const targetScore = lessonData.targetScorePercent || 80;
|
|
|
|
const [progress] = await VocabCourseProgress.findOrCreate({
|
|
where: { userId: user.id, lessonId: lesson.id },
|
|
defaults: {
|
|
userId: user.id,
|
|
courseId: cid,
|
|
lessonId: lesson.id,
|
|
completed: false,
|
|
score: 0,
|
|
lessonState: {}
|
|
}
|
|
});
|
|
|
|
if (progress.completed) {
|
|
details.push({
|
|
lessonNumber: lesson.lessonNumber,
|
|
lessonId: lesson.id,
|
|
status: 'unchanged'
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const mergedState = this._applyScheduledReviewState(
|
|
this._sanitizeLessonState(progress.lessonState),
|
|
{
|
|
previousCompleted: false,
|
|
nextCompleted: true,
|
|
shouldAdvanceReview: true,
|
|
lessonData,
|
|
now
|
|
}
|
|
);
|
|
|
|
await progress.update({
|
|
completed: true,
|
|
completedAt: now,
|
|
score: Math.max(Number(progress.score) || 0, targetScore),
|
|
lastAccessedAt: now,
|
|
lessonState: mergedState
|
|
});
|
|
|
|
details.push({
|
|
lessonNumber: lesson.lessonNumber,
|
|
lessonId: lesson.id,
|
|
status: 'marked_complete'
|
|
});
|
|
}
|
|
|
|
return {
|
|
courseId: cid,
|
|
throughLessonNumber: maxNum,
|
|
lessonsConsidered: lessons.length,
|
|
details
|
|
};
|
|
}
|
|
|
|
// ========== GRAMMAR EXERCISE METHODS ==========
|
|
|
|
async getExerciseTypes() {
|
|
const types = await VocabGrammarExerciseType.findAll({
|
|
order: [['name', 'ASC']]
|
|
});
|
|
return types.map(t => t.get({ plain: true }));
|
|
}
|
|
|
|
async createGrammarExercise(hashedUserId, lessonId, { exerciseTypeId, exerciseNumber, title, instruction, questionData, answerData, explanation }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe, ob User Besitzer des Kurses ist
|
|
if (lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can add grammar exercises');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const exercise = await VocabGrammarExercise.create({
|
|
lessonId: lesson.id,
|
|
exerciseTypeId: Number(exerciseTypeId),
|
|
exerciseNumber: Number(exerciseNumber),
|
|
title,
|
|
instruction,
|
|
questionData,
|
|
answerData,
|
|
explanation,
|
|
createdByUserId: user.id
|
|
});
|
|
|
|
return exercise.get({ plain: true });
|
|
}
|
|
|
|
async getGrammarExercisesForLesson(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [{ model: VocabCourse, as: 'course' }]
|
|
});
|
|
|
|
if (!lesson) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: { lessonId: lesson.id },
|
|
include: [{ model: VocabGrammarExerciseType, as: 'exerciseType' }],
|
|
order: [['exerciseNumber', 'ASC']]
|
|
});
|
|
|
|
const plainLesson = lesson.get({ plain: true });
|
|
const list = exercises.map((e) => e.get({ plain: true }));
|
|
return await this._mergeSyntheticChapterLexemeMcExercises(plainLesson, list);
|
|
}
|
|
|
|
async getGrammarExercise(hashedUserId, exerciseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] },
|
|
{ model: VocabGrammarExerciseType, as: 'exerciseType' }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Zugriff
|
|
if (exercise.lesson.course.ownerUserId !== user.id && !exercise.lesson.course.isPublic) {
|
|
const err = new Error('Access denied');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
return exercise.get({ plain: true });
|
|
}
|
|
|
|
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exIdStr = String(exerciseId ?? '');
|
|
const synMatch = /^syn-(\d+)-(\d+)-l2r$/.exec(exIdStr);
|
|
if (synMatch) {
|
|
return this._checkSyntheticLexemeMcAnswer(
|
|
user,
|
|
Number(synMatch[1]),
|
|
Number(synMatch[2]),
|
|
userAnswer
|
|
);
|
|
}
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
// Prüfe Einschreibung
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: exercise.lesson.courseId }
|
|
});
|
|
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const originalAnswerData = typeof exercise.answerData === 'string'
|
|
? JSON.parse(exercise.answerData)
|
|
: exercise.answerData;
|
|
const questionData = typeof exercise.questionData === 'string'
|
|
? JSON.parse(exercise.questionData)
|
|
: exercise.questionData;
|
|
const effectiveAnswerData = exercise.exerciseTypeId === 2
|
|
? await this._expandMultipleChoiceAnswerData(exercise, originalAnswerData, questionData)
|
|
: originalAnswerData;
|
|
|
|
// Überprüfe Antwort
|
|
const isCorrect = this._checkAnswer(effectiveAnswerData, questionData, userAnswer, exercise.exerciseTypeId);
|
|
|
|
// Speichere Fortschritt
|
|
const [progress, created] = await VocabGrammarExerciseProgress.findOrCreate({
|
|
where: { userId: user.id, exerciseId: exercise.id },
|
|
defaults: {
|
|
userId: user.id,
|
|
exerciseId: exercise.id,
|
|
attempts: 1,
|
|
correctAttempts: isCorrect ? 1 : 0,
|
|
lastAttemptAt: new Date(),
|
|
completed: false
|
|
}
|
|
});
|
|
|
|
if (!created) {
|
|
progress.attempts += 1;
|
|
if (isCorrect) {
|
|
progress.correctAttempts += 1;
|
|
if (!progress.completed) {
|
|
progress.completed = true;
|
|
progress.completedAt = new Date();
|
|
}
|
|
}
|
|
progress.lastAttemptAt = new Date();
|
|
await progress.save();
|
|
} else if (isCorrect) {
|
|
progress.completed = true;
|
|
progress.completedAt = new Date();
|
|
await progress.save();
|
|
}
|
|
|
|
// Extrahiere richtige Antwort und Alternativen
|
|
const answerData = effectiveAnswerData;
|
|
|
|
let correctAnswer = null;
|
|
let alternatives = [];
|
|
|
|
// Für Multiple Choice: Extrahiere die richtige(n) Antwort(en) aus dem Index/den Indizes
|
|
if (exercise.exerciseTypeId === 2 && answerData.correctAnswer !== undefined) {
|
|
const options = questionData?.options || [];
|
|
|
|
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
|
|
let correctIndices = [];
|
|
if (Array.isArray(answerData.correctAnswer)) {
|
|
correctIndices = answerData.correctAnswer.map(idx => Number(idx));
|
|
} else {
|
|
correctIndices = [Number(answerData.correctAnswer)];
|
|
}
|
|
|
|
// Extrahiere alle korrekten Antworten
|
|
const correctAnswersList = correctIndices
|
|
.map(idx => options[idx])
|
|
.filter(opt => opt !== undefined);
|
|
|
|
if (correctAnswersList.length > 0) {
|
|
// Wenn mehrere richtige Antworten: Zeige alle an, getrennt durch " / "
|
|
correctAnswer = correctAnswersList.join(' / ');
|
|
}
|
|
|
|
// Alternativen sind alle anderen Optionen (nicht korrekte)
|
|
alternatives = options.filter((opt, idx) => !correctIndices.includes(idx));
|
|
}
|
|
// Für Gap Fill: Extrahiere aus answers Array
|
|
else if (exercise.exerciseTypeId === 1 && answerData.answers) {
|
|
correctAnswer = Array.isArray(answerData.answers)
|
|
? answerData.answers.join(', ')
|
|
: answerData.answers;
|
|
}
|
|
// Für Reading Aloud: Extrahiere den erwarteten Text
|
|
else if (questionData.type === 'reading_aloud') {
|
|
correctAnswer = questionData.text || answerData.expectedText || '';
|
|
}
|
|
// Für Speaking From Memory: Extrahiere erwarteten Text oder Schlüsselwörter
|
|
else if (questionData.type === 'speaking_from_memory') {
|
|
correctAnswer = questionData.expectedText || questionData.text || '';
|
|
alternatives = questionData.keywords || [];
|
|
}
|
|
else if (questionData.type === 'sentence_building' || questionData.type === 'dialog_completion' || questionData.type === 'situational_response' || questionData.type === 'pattern_drill') {
|
|
const rawCorrect = answerData.correct ?? answerData.correctAnswer ?? answerData.answers ?? answerData.modelAnswer;
|
|
if (Array.isArray(rawCorrect)) {
|
|
correctAnswer = rawCorrect.join(' / ');
|
|
} else {
|
|
correctAnswer = rawCorrect || questionData.modelAnswer || '';
|
|
}
|
|
alternatives = answerData.alternatives || questionData.keywords || [];
|
|
}
|
|
// Fallback: Versuche correct oder correctAnswer
|
|
else {
|
|
correctAnswer = Array.isArray(answerData.correct)
|
|
? answerData.correct[0]
|
|
: (answerData.correct || answerData.correctAnswer);
|
|
alternatives = answerData.alternatives || [];
|
|
}
|
|
|
|
return {
|
|
correct: isCorrect,
|
|
correctAnswer: correctAnswer,
|
|
alternatives: alternatives,
|
|
explanation: exercise.explanation,
|
|
progress: progress.get({ plain: true })
|
|
};
|
|
}
|
|
|
|
async _getExerciseTypeIdByName(typeName) {
|
|
const type = await VocabGrammarExerciseType.findOne({ where: { name: typeName } });
|
|
return type ? type.id : null;
|
|
}
|
|
|
|
_extractMultipleChoiceIndices(answerData) {
|
|
if (!answerData) return [];
|
|
if (answerData.correctAnswer !== undefined) {
|
|
return Array.isArray(answerData.correctAnswer)
|
|
? answerData.correctAnswer.map(idx => Number(idx)).filter(Number.isInteger)
|
|
: [Number(answerData.correctAnswer)].filter(Number.isInteger);
|
|
}
|
|
if (answerData.correct !== undefined) {
|
|
return Array.isArray(answerData.correct)
|
|
? answerData.correct.map(idx => Number(idx)).filter(Number.isInteger)
|
|
: [Number(answerData.correct)].filter(Number.isInteger);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
_getMultipleChoicePrompt(questionData) {
|
|
return this._normalizeTextAnswer(
|
|
questionData?.question || questionData?.text || questionData?.prompt || ''
|
|
);
|
|
}
|
|
|
|
async _expandMultipleChoiceAnswerData(exercise, answerData, questionData) {
|
|
const options = Array.isArray(questionData?.options) ? questionData.options : [];
|
|
const baseIndices = this._extractMultipleChoiceIndices(answerData);
|
|
if (!options.length || !baseIndices.length || !exercise?.lessonId) {
|
|
return answerData;
|
|
}
|
|
|
|
const prompt = this._getMultipleChoicePrompt(questionData);
|
|
if (!prompt) {
|
|
return answerData;
|
|
}
|
|
|
|
const optionIndexMap = new Map();
|
|
options.forEach((option, index) => {
|
|
const normalizedOption = this._normalizeTextAnswer(option);
|
|
if (!normalizedOption) return;
|
|
const existing = optionIndexMap.get(normalizedOption) || [];
|
|
existing.push(index);
|
|
optionIndexMap.set(normalizedOption, existing);
|
|
});
|
|
|
|
const lessonExercises = await VocabGrammarExercise.findAll({
|
|
where: {
|
|
lessonId: exercise.lessonId,
|
|
exerciseTypeId: 2
|
|
},
|
|
attributes: ['id', 'questionData', 'answerData']
|
|
});
|
|
|
|
const expandedIndices = new Set(baseIndices);
|
|
lessonExercises.forEach((candidate) => {
|
|
const candidateQuestionData = typeof candidate.questionData === 'string'
|
|
? JSON.parse(candidate.questionData)
|
|
: candidate.questionData;
|
|
const candidatePrompt = this._getMultipleChoicePrompt(candidateQuestionData);
|
|
if (candidatePrompt !== prompt) {
|
|
return;
|
|
}
|
|
|
|
const candidateAnswerData = typeof candidate.answerData === 'string'
|
|
? JSON.parse(candidate.answerData)
|
|
: candidate.answerData;
|
|
const candidateOptions = Array.isArray(candidateQuestionData?.options) ? candidateQuestionData.options : [];
|
|
const candidateIndices = this._extractMultipleChoiceIndices(candidateAnswerData);
|
|
|
|
candidateIndices.forEach((candidateIndex) => {
|
|
const candidateOption = candidateOptions[candidateIndex];
|
|
const normalizedOption = this._normalizeTextAnswer(candidateOption);
|
|
const matchingIndices = optionIndexMap.get(normalizedOption) || [];
|
|
matchingIndices.forEach((matchingIndex) => expandedIndices.add(matchingIndex));
|
|
});
|
|
});
|
|
|
|
return {
|
|
...answerData,
|
|
correctAnswer: Array.from(expandedIndices).sort((a, b) => a - b)
|
|
};
|
|
}
|
|
|
|
_checkAnswer(answerData, questionData, userAnswer, exerciseTypeId) {
|
|
// Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden
|
|
if (!answerData || userAnswer === undefined || userAnswer === null) return false;
|
|
|
|
// Parse JSON strings
|
|
const parsedAnswerData = typeof answerData === 'string' ? JSON.parse(answerData) : answerData;
|
|
const parsedQuestionData = typeof questionData === 'string' ? JSON.parse(questionData) : questionData;
|
|
|
|
// Für Multiple Choice: Prüfe ob userAnswer (Index) mit correctAnswer (Index oder Array von Indizes) übereinstimmt
|
|
if (exerciseTypeId === 2) { // multiple_choice
|
|
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
|
|
let correctIndices = [];
|
|
|
|
if (parsedAnswerData.correctAnswer !== undefined) {
|
|
// Kann ein einzelner Index oder ein Array von Indizes sein
|
|
if (Array.isArray(parsedAnswerData.correctAnswer)) {
|
|
correctIndices = parsedAnswerData.correctAnswer.map(idx => Number(idx));
|
|
} else {
|
|
correctIndices = [Number(parsedAnswerData.correctAnswer)];
|
|
}
|
|
} else if (parsedAnswerData.correct !== undefined) {
|
|
// Fallback: Prüfe auch 'correct' Feld
|
|
if (Array.isArray(parsedAnswerData.correct)) {
|
|
correctIndices = parsedAnswerData.correct.map(idx => Number(idx));
|
|
} else {
|
|
correctIndices = [Number(parsedAnswerData.correct)];
|
|
}
|
|
}
|
|
|
|
if (correctIndices.length === 0) return false;
|
|
|
|
const options = Array.isArray(parsedQuestionData?.options) ? parsedQuestionData.options : [];
|
|
|
|
const correctTexts = correctIndices
|
|
.map((i) => options[i])
|
|
.filter((opt) => opt !== undefined && opt !== null);
|
|
const norm = (s) => this._normalizeTextAnswer(s);
|
|
|
|
// Nach zufälligen Distraktoren: Client sendet gewählten Optionstext statt Index
|
|
if (typeof userAnswer === 'string') {
|
|
const u = norm(userAnswer);
|
|
if (!u) return false;
|
|
return correctTexts.some((t) => this._isEquivalentAnswer(userAnswer, t));
|
|
}
|
|
|
|
// Legacy: Index in die gespeicherten (nicht gemischten) Optionen
|
|
const userIndex = Number(userAnswer);
|
|
if (Number.isNaN(userIndex)) return false;
|
|
return correctIndices.includes(userIndex);
|
|
}
|
|
|
|
// Für Lückentext: Normalisiere und vergleiche
|
|
if (exerciseTypeId === 1) { // gap_fill
|
|
const normalize = (str) => String(str || '').trim().toLowerCase();
|
|
const correctAnswers = parsedAnswerData.answers || parsedAnswerData.correct || [];
|
|
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
|
|
|
|
// userAnswer ist ein Array von Antworten
|
|
if (Array.isArray(userAnswer)) {
|
|
if (userAnswer.length !== correctAnswersArray.length) return false;
|
|
return userAnswer.every((ans, idx) => {
|
|
const correct = correctAnswersArray[idx];
|
|
return normalize(ans) === normalize(correct);
|
|
});
|
|
} else {
|
|
// Fallback: Einzelne Antwort
|
|
const normalizedUserAnswer = normalize(userAnswer);
|
|
return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer);
|
|
}
|
|
}
|
|
|
|
// Für Reading Aloud: userAnswer ist der erkannte Text (String)
|
|
// Vergleiche mit dem erwarteten Text aus questionData.text
|
|
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') {
|
|
const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
|
|
const normalizedExpected = this._normalizeTextAnswer(expectedText);
|
|
const normalizedUser = this._normalizeTextAnswer(userAnswer);
|
|
|
|
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
|
|
if (parsedQuestionData.type === 'reading_aloud') {
|
|
// Exakter Vergleich (kann später mit Levenshtein erweitert werden)
|
|
return normalizedUser === normalizedExpected;
|
|
}
|
|
|
|
// Für speaking_from_memory: Flexibler Vergleich (Schlüsselwörter)
|
|
if (parsedQuestionData.type === 'speaking_from_memory') {
|
|
const keywords = parsedQuestionData.keywords || [];
|
|
if (keywords.length === 0) {
|
|
// Fallback: Exakter Vergleich
|
|
return normalizedUser === normalizedExpected;
|
|
}
|
|
// Prüfe ob alle Schlüsselwörter vorhanden sind
|
|
return keywords.every(keyword => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
|
|
}
|
|
}
|
|
|
|
if (parsedQuestionData.type === 'sentence_building' || parsedQuestionData.type === 'dialog_completion' || parsedQuestionData.type === 'situational_response' || parsedQuestionData.type === 'pattern_drill') {
|
|
const candidateAnswers = parsedAnswerData.correct ?? parsedAnswerData.correctAnswer ?? parsedAnswerData.answers ?? parsedAnswerData.modelAnswer ?? [];
|
|
const normalizedUser = this._normalizeTextAnswer(userAnswer);
|
|
const answers = Array.isArray(candidateAnswers) ? candidateAnswers : [candidateAnswers];
|
|
|
|
if (parsedQuestionData.type === 'situational_response') {
|
|
const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || [];
|
|
if (keywords.length > 0) {
|
|
return keywords.every((keyword) => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
|
|
}
|
|
}
|
|
|
|
return answers
|
|
.map((answer) => this._normalizeTextAnswer(answer))
|
|
.filter(Boolean)
|
|
.some((answer) => answer === normalizedUser);
|
|
}
|
|
|
|
// Für andere Typen: einfacher String-Vergleich inkl. hinterlegter Alternativen
|
|
const primaryAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
|
|
const primaryAnswersArray = Array.isArray(primaryAnswers) ? primaryAnswers : [primaryAnswers];
|
|
const alternativeAnswersArray = Array.isArray(parsedAnswerData.alternatives)
|
|
? parsedAnswerData.alternatives
|
|
: [];
|
|
const correctAnswersArray = [...primaryAnswersArray, ...alternativeAnswersArray];
|
|
const normalizedUserAnswer = this._normalizeTextAnswer(userAnswer);
|
|
return correctAnswersArray.some(correct => this._normalizeTextAnswer(correct) === normalizedUserAnswer);
|
|
}
|
|
|
|
async getGrammarExerciseProgress(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId);
|
|
|
|
const numericExerciseIds = exercises
|
|
.map((e) => e.id)
|
|
.filter((id) => /^\d+$/.test(String(id)));
|
|
const progress = numericExerciseIds.length
|
|
? await VocabGrammarExerciseProgress.findAll({
|
|
where: {
|
|
userId: user.id,
|
|
exerciseId: { [Op.in]: numericExerciseIds }
|
|
}
|
|
})
|
|
: [];
|
|
|
|
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
|
|
|
|
return exercises.map(exercise => ({
|
|
...exercise,
|
|
progress: progressMap.get(exercise.id) || null
|
|
}));
|
|
}
|
|
|
|
async updateGrammarExercise(hashedUserId, exerciseId, { title, instruction, questionData, answerData, explanation, exerciseNumber }) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (exercise.lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can update exercises');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const updates = {};
|
|
if (title !== undefined) updates.title = title;
|
|
if (instruction !== undefined) updates.instruction = instruction;
|
|
if (questionData !== undefined) updates.questionData = questionData;
|
|
if (answerData !== undefined) updates.answerData = answerData;
|
|
if (explanation !== undefined) updates.explanation = explanation;
|
|
if (exerciseNumber !== undefined) updates.exerciseNumber = Number(exerciseNumber);
|
|
|
|
await exercise.update(updates);
|
|
return exercise.get({ plain: true });
|
|
}
|
|
|
|
async deleteGrammarExercise(hashedUserId, exerciseId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
|
|
include: [
|
|
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
|
|
]
|
|
});
|
|
|
|
if (!exercise) {
|
|
const err = new Error('Exercise not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
if (exercise.lesson.course.ownerUserId !== user.id) {
|
|
const err = new Error('Only the owner can delete exercises');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
await exercise.destroy();
|
|
return { success: true };
|
|
}
|
|
|
|
/**
|
|
* Explizite Zuordnung der Antwortsprache (sprachneutral).
|
|
* questionData.answerLanguage: 'target' | 'native'
|
|
* oder questionData.answerLanguageId: 1 = target (Lernsprache), 2 = native (Muttersprache)
|
|
* Ohne diese Felder: 'unknown' (kein Eintrag in den Distraktor-Pools für diese Frage).
|
|
* @param {object} questionData
|
|
* @returns {'target'|'native'|'unknown'}
|
|
*/
|
|
_resolveMcAnswerSide(questionData) {
|
|
if (!questionData || typeof questionData !== 'object') return 'unknown';
|
|
const raw = questionData.answerLanguage;
|
|
if (typeof raw === 'string') {
|
|
const s = raw.trim().toLowerCase();
|
|
if (s === 'target' || s === 'learning' || s === 'l2') return 'target';
|
|
if (s === 'native' || s === 'l1') return 'native';
|
|
}
|
|
const id = questionData.answerLanguageId;
|
|
if (id === 1 || id === '1') return 'target';
|
|
if (id === 2 || id === '2') return 'native';
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Sammelt Vokabeln aus allen Multiple-Choice-Übungen von Lektionen **vor** der angegebenen Lektion
|
|
* (gleicher Kurs), getrennt nach Ziel- vs. Muttersprache anhand von answerLanguage / answerLanguageId.
|
|
*/
|
|
async getVocabDistractorPool(hashedUserId, courseId, beforeLessonId) {
|
|
if (!beforeLessonId) {
|
|
const err = new Error('beforeLessonId is required');
|
|
err.status = 400;
|
|
throw err;
|
|
}
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const enrollment = await VocabCourseEnrollment.findOne({
|
|
where: { userId: user.id, courseId: Number(courseId) },
|
|
});
|
|
if (!enrollment) {
|
|
const err = new Error('Not enrolled in this course');
|
|
err.status = 403;
|
|
throw err;
|
|
}
|
|
|
|
const currentLesson = await VocabCourseLesson.findByPk(beforeLessonId);
|
|
if (!currentLesson || currentLesson.courseId !== Number(courseId)) {
|
|
const err = new Error('Lesson not found');
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
|
|
const priorLessons = await VocabCourseLesson.findAll({
|
|
where: {
|
|
courseId: Number(courseId),
|
|
lessonNumber: { [Op.lt]: currentLesson.lessonNumber },
|
|
},
|
|
attributes: ['id'],
|
|
order: [['lessonNumber', 'ASC']],
|
|
});
|
|
|
|
const lessonIds = priorLessons.map((l) => l.id);
|
|
if (lessonIds.length === 0) {
|
|
return { target: [], native: [] };
|
|
}
|
|
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: {
|
|
lessonId: { [Op.in]: lessonIds },
|
|
exerciseTypeId: 2,
|
|
},
|
|
attributes: ['questionData'],
|
|
});
|
|
|
|
const target = new Set();
|
|
const native = new Set();
|
|
|
|
for (const ex of exercises) {
|
|
const qd =
|
|
typeof ex.questionData === 'string' ? JSON.parse(ex.questionData) : ex.questionData;
|
|
const opts = qd?.options;
|
|
if (!Array.isArray(opts)) continue;
|
|
const side = this._resolveMcAnswerSide(qd);
|
|
if (side === 'target') {
|
|
opts.forEach((o) => target.add(String(o).trim()));
|
|
} else if (side === 'native') {
|
|
opts.forEach((o) => native.add(String(o).trim()));
|
|
}
|
|
}
|
|
|
|
return {
|
|
target: [...target],
|
|
native: [...native],
|
|
};
|
|
}
|
|
}
|