feat(vocab): enhance lesson state management and persistence in VocabLessonView
All checks were successful
Deploy to production / deploy (push) Successful in 3m20s

- Added a new JSONB field `lessonState` to the VocabCourseProgress model to store detailed lesson state information.
- Implemented methods in VocabService for sanitizing and serializing lesson state, ensuring robust data handling.
- Updated VocabLessonView to manage lesson state persistence, including local storage and server synchronization, improving user experience during vocabulary lessons.
- Introduced mechanisms for exporting and normalizing exercise answers, enhancing the accuracy of saved progress.
This commit is contained in:
Torsten Schulz (local)
2026-04-01 11:16:56 +02:00
parent 84adfeafb4
commit a3b820cea0
4 changed files with 505 additions and 12 deletions

View File

@@ -0,0 +1,20 @@
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE community.vocab_course_progress
ADD COLUMN IF NOT EXISTS lesson_state JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN community.vocab_course_progress.lesson_state IS
'Persistierter UI- und Lernzustand pro Nutzer und Lektion fuer Resume im Sprachkurs.';
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE community.vocab_course_progress
DROP COLUMN IF EXISTS lesson_state;
`);
}
};

View File

@@ -34,6 +34,12 @@ VocabCourseProgress.init({
allowNull: false,
defaultValue: 0
},
lessonState: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: {},
field: 'lesson_state'
},
lastAccessedAt: {
type: DataTypes.DATE,
allowNull: true,

View File

@@ -15,6 +15,181 @@ import { Op } from 'sequelize';
import { BISAYA_PHASE1_DIDACTICS } from '../scripts/bisaya-course-phase1.js';
export default class VocabService {
_clampInteger(value, { min = 0, max = 100000, fallback = 0 } = {}) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.trunc(numeric)));
}
_sanitizeShortString(value, maxLength = 400) {
const text = String(value ?? '').trim();
if (!text) {
return '';
}
return text.slice(0, maxLength);
}
_sanitizeStringArray(value, { maxItems = 12, maxLength = 400, keepEmpty = false } = {}) {
if (!Array.isArray(value)) {
return [];
}
return value
.slice(0, maxItems)
.map((entry) => this._sanitizeShortString(entry, maxLength))
.filter((entry) => keepEmpty || Boolean(entry));
}
_sanitizeExerciseAnswers(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const sanitized = {};
Object.entries(value).slice(0, 200).forEach(([exerciseId, answer]) => {
if (!/^\d+$/.test(String(exerciseId))) {
return;
}
if (Array.isArray(answer)) {
sanitized[exerciseId] = this._sanitizeStringArray(answer, {
maxItems: 12,
maxLength: 200,
keepEmpty: true
});
return;
}
if (typeof answer === 'string') {
sanitized[exerciseId] = this._sanitizeShortString(answer, 200);
return;
}
if (typeof answer === 'number' && Number.isFinite(answer)) {
sanitized[exerciseId] = Math.trunc(answer);
}
});
return sanitized;
}
_sanitizeExerciseResults(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const sanitized = {};
Object.entries(value).slice(0, 200).forEach(([exerciseId, result]) => {
if (!/^\d+$/.test(String(exerciseId)) || !result || typeof result !== 'object' || Array.isArray(result)) {
return;
}
sanitized[exerciseId] = {
correct: Boolean(result.correct),
correctAnswer: this._sanitizeShortString(result.correctAnswer, 400),
alternatives: this._sanitizeStringArray(result.alternatives, { maxItems: 8, maxLength: 200 }),
explanation: this._sanitizeShortString(result.explanation, 1200)
};
});
return sanitized;
}
_sanitizeVocabTrainerStats(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const sanitized = {};
Object.entries(value).slice(0, 400).forEach(([key, stats]) => {
const safeKey = this._sanitizeShortString(key, 200);
if (!safeKey || !stats || typeof stats !== 'object' || Array.isArray(stats)) {
return;
}
sanitized[safeKey] = {
attempts: this._clampInteger(stats.attempts, { max: 5000 }),
correct: this._clampInteger(stats.correct, { max: 5000 }),
wrong: this._clampInteger(stats.wrong, { max: 5000 })
};
});
return sanitized;
}
_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',
'exerciseAnswers',
'exerciseResults',
'exerciseRetryPending',
'exerciseRetryPendingSinceAttempts'
];
const hasKnownState = knownKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key));
if (!hasKnownState) {
return {};
}
const activeTab = value.activeTab === 'exercises' ? 'exercises' : 'learn';
const vocabTrainerMode = value.vocabTrainerMode === 'typing' ? 'typing' : 'multiple_choice';
return {
version: this._clampInteger(value.version, { min: 1, max: 1000, fallback: 1 }),
updatedAt: this._sanitizeShortString(value.updatedAt || new Date().toISOString(), 64),
activeTab,
exercisePreparationCompleted: Boolean(value.exercisePreparationCompleted),
lessonPrepStage: this._clampInteger(value.lessonPrepStage, { min: 0, max: 2 }),
lessonPrepIndex: this._clampInteger(value.lessonPrepIndex, { min: 0, max: 500 }),
vocabTrainerActive: Boolean(value.vocabTrainerActive),
vocabTrainerMode,
vocabTrainerAutoSwitchedToTyping: Boolean(value.vocabTrainerAutoSwitchedToTyping),
vocabTrainerCorrect: this._clampInteger(value.vocabTrainerCorrect, { max: 5000 }),
vocabTrainerWrong: this._clampInteger(value.vocabTrainerWrong, { max: 5000 }),
vocabTrainerTotalAttempts: this._clampInteger(value.vocabTrainerTotalAttempts, { max: 10000 }),
vocabTrainerCurrentAttempts: this._clampInteger(value.vocabTrainerCurrentAttempts, { max: 10000 }),
vocabTrainerReviewAttempts: this._clampInteger(value.vocabTrainerReviewAttempts, { max: 10000 }),
vocabTrainerStats: this._sanitizeVocabTrainerStats(value.vocabTrainerStats),
exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers),
exerciseResults: this._sanitizeExerciseResults(value.exerciseResults),
exerciseRetryPending: Boolean(value.exerciseRetryPending),
exerciseRetryPendingSinceAttempts: this._clampInteger(value.exerciseRetryPendingSinceAttempts, { max: 10000 })
};
}
_serializeLessonProgress(progress, lessonData = null) {
if (!progress) {
return null;
}
const plainProgress = progress.get ? progress.get({ plain: true }) : { ...progress };
const targetScore = lessonData?.targetScorePercent || plainProgress.lesson?.targetScorePercent || 80;
const hasReachedTarget = (plainProgress.score || 0) >= targetScore;
return {
...plainProgress,
lessonState: this._sanitizeLessonState(plainProgress.lessonState),
targetScore,
hasReachedTarget,
needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget)
};
}
async _getUserByHashedId(hashedUserId) {
const user = await User.findOne({ where: { hashedId: hashedUserId } });
if (!user) {
@@ -1652,6 +1827,13 @@ export default class VocabService {
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)
@@ -1666,6 +1848,7 @@ export default class VocabService {
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
plainLesson.pedagogy = this._buildLessonPedagogy(plainLesson);
plainLesson.progress = this._serializeLessonProgress(progress, plainLesson);
return plainLesson;
}
@@ -2053,10 +2236,10 @@ export default class VocabService {
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']]
});
return progress.map(p => p.get({ plain: true }));
return progress.map((entry) => this._serializeLessonProgress(entry, entry.lesson));
}
async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes }) {
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' }]
@@ -2083,6 +2266,7 @@ export default class VocabService {
const targetScore = lessonData.targetScorePercent || 80;
const actualScore = Number(score) || 0;
const hasReachedTarget = actualScore >= targetScore;
const sanitizedLessonState = lessonState === undefined ? undefined : this._sanitizeLessonState(lessonState);
// Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true)
const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false);
@@ -2095,6 +2279,7 @@ export default class VocabService {
lessonId: lesson.id,
completed: isCompleted,
score: actualScore,
lessonState: sanitizedLessonState || {},
lastAccessedAt: new Date()
}
});
@@ -2117,6 +2302,9 @@ export default class VocabService {
updates.completedAt = new Date();
}
}
if (sanitizedLessonState !== undefined) {
updates.lessonState = sanitizedLessonState;
}
await progress.update(updates);
} else if (isCompleted) {
progress.completed = true;
@@ -2124,12 +2312,7 @@ export default class VocabService {
await progress.save();
}
const progressData = progress.get({ plain: true });
progressData.targetScore = targetScore;
progressData.hasReachedTarget = progressData.score >= targetScore;
progressData.needsReview = lessonData.requiresReview && !progressData.hasReachedTarget;
return progressData;
return this._serializeLessonProgress(progress, lessonData);
}
// ========== GRAMMAR EXERCISE METHODS ==========