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