feat(vocab): add lesson and completed lesson vocab pool endpoints
All checks were successful
Deploy to production / deploy (push) Successful in 3m5s

- Implemented new endpoints in VocabController for retrieving vocab pools based on lessons and completed lessons.
- Updated vocabRouter to include routes for accessing lesson vocab pools and completed lesson vocab pools.
- Enhanced VocabService with methods to extract vocab from exercises and lesson didactics, improving vocabulary retrieval for users.
- Modified VocabPracticeDialog and VocabCourseView components to support new vocab pool functionalities, enhancing user experience in vocabulary practice.
This commit is contained in:
Torsten Schulz (local)
2026-03-31 11:59:48 +02:00
parent 01293b0102
commit 3187a6e7b0
5 changed files with 313 additions and 9 deletions

View File

@@ -22,11 +22,15 @@ class VocabController {
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
this.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 });
this.getLessonVocabPool = this._wrapWithUser((userId, req) => this.service.getLessonVocabPool(userId, req.params.lessonId));
// Courses
this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 });
this.getCourses = this._wrapWithUser((userId, req) => this.service.getCourses(userId, req.query));
this.getCourse = this._wrapWithUser((userId, req) => this.service.getCourse(userId, req.params.courseId));
this.getCompletedLessonVocabPool = this._wrapWithUser((userId, req) =>
this.service.getCompletedLessonVocabPool(userId, req.params.courseId, req.query.untilLessonId)
);
this.getVocabDistractorPool = this._wrapWithUser((userId, req) =>
this.service.getVocabDistractorPool(userId, req.params.courseId, req.query.beforeLessonId)
);
@@ -80,4 +84,3 @@ class VocabController {
}
export default VocabController;

View File

@@ -22,12 +22,14 @@ router.get('/languages/:languageId/search', vocabController.searchVocabs);
router.get('/chapters/:chapterId', vocabController.getChapter);
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
router.get('/lessons/:lessonId/vocab-pool', vocabController.getLessonVocabPool);
// Courses
router.post('/courses', vocabController.createCourse);
router.get('/courses', vocabController.getCourses);
router.get('/courses/my', vocabController.getMyCourses);
router.post('/courses/find-by-code', vocabController.getCourseByShareCode);
router.get('/courses/:courseId/completed-lesson-vocabs', vocabController.getCompletedLessonVocabPool);
router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool);
router.get('/courses/:courseId', vocabController.getCourse);
router.put('/courses/:courseId', vocabController.updateCourse);
@@ -59,4 +61,3 @@ router.put('/grammar-exercises/:exerciseId', vocabController.updateGrammarExerci
router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExercise);
export default router;

View File

@@ -150,6 +150,110 @@ export default class VocabService {
.replace(/\s+/g, ' ');
}
_parseExercisePayload(value) {
if (!value) return {};
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return {};
}
}
if (typeof value === 'object') {
return value;
}
return {};
}
_extractTrainerVocabsFromExercises(exercises = []) {
const vocabMap = new Map();
exercises.forEach((exercise) => {
try {
const qData = this._parseExercisePayload(exercise.questionData);
const aData = this._parseExercisePayload(exercise.answerData);
const exerciseType = exercise.exerciseType?.name || qData.type || '';
if (exerciseType === 'multiple_choice') {
const options = Array.isArray(qData.options) ? qData.options : [];
const correctAnswer = Array.isArray(aData.correctAnswer)
? options[aData.correctAnswer[0]]
: options[aData.correctAnswer ?? aData.correct ?? 0];
const question = String(qData.question || qData.text || '');
let match = question.match(/Wie sagt man ['"]([^'"]+)['"]/i);
if (match && match[1] && correctAnswer && match[1].trim() !== String(correctAnswer).trim()) {
vocabMap.set(`${match[1]}-${correctAnswer}`, {
learning: match[1],
reference: String(correctAnswer)
});
return;
}
match = question.match(/Was bedeutet ['"]([^'"]+)['"]/i);
if (match && match[1] && correctAnswer && match[1].trim() !== String(correctAnswer).trim()) {
vocabMap.set(`${correctAnswer}-${match[1]}`, {
learning: String(correctAnswer),
reference: match[1]
});
}
return;
}
if (exerciseType === 'gap_fill') {
const answers = Array.isArray(aData.answers)
? aData.answers
: (aData.correct ? (Array.isArray(aData.correct) ? aData.correct : [aData.correct]) : []);
const text = String(qData.text || '');
const nativeWords = Array.from(text.matchAll(/\(([^)]+)\)/g), (m) => String(m[1] || '').trim());
if (!answers.length || !nativeWords.length) {
return;
}
answers.forEach((answer, index) => {
const nativeWord = nativeWords[index];
const normalizedAnswer = String(answer || '').trim();
if (!nativeWord || !normalizedAnswer || nativeWord === normalizedAnswer) {
return;
}
vocabMap.set(`${nativeWord}-${normalizedAnswer}`, {
learning: nativeWord,
reference: normalizedAnswer
});
});
}
} catch (error) {
console.warn('Fehler beim Extrahieren von Trainer-Vokabeln:', error);
}
});
return Array.from(vocabMap.values());
}
_extractTrainerVocabsFromLessonDidactics(lesson) {
const vocabMap = new Map();
const speakingPrompts = Array.isArray(lesson?.speakingPrompts) ? lesson.speakingPrompts : [];
const practicalTasks = Array.isArray(lesson?.practicalTasks) ? lesson.practicalTasks : [];
const corePatterns = Array.isArray(lesson?.corePatterns) ? lesson.corePatterns : [];
speakingPrompts.forEach((prompt, index) => {
const learning = String(prompt?.prompt || prompt?.title || '').trim();
const reference = String(prompt?.cue || corePatterns[index] || corePatterns[0] || '').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 reference = String(corePatterns[index] || corePatterns[0] || '').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)) {
@@ -810,6 +914,175 @@ export default class VocabService {
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
}
async getLessonVocabPool(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [
{
model: VocabCourse,
as: 'course'
},
{
model: VocabGrammarExercise,
as: 'grammarExercises',
include: [
{
model: VocabGrammarExerciseType,
as: 'exerciseType'
}
],
required: false
}
]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const progress = await VocabCourseProgress.findOne({
where: {
userId: user.id,
lessonId: lesson.id
}
});
if (lesson.course.ownerUserId !== user.id && !progress?.completed) {
const err = new Error('Lesson must be completed first');
err.status = 403;
throw err;
}
const extractedFromExercises = this._extractTrainerVocabsFromExercises(
(lesson.grammarExercises || []).map((exercise) => exercise.get({ plain: true }))
);
const vocabs = extractedFromExercises.length > 0
? extractedFromExercises
: this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }));
return {
lesson: {
id: lesson.id,
title: lesson.title,
courseId: lesson.courseId,
courseTitle: lesson.course.title
},
vocabs
};
}
async getCompletedLessonVocabPool(hashedUserId, courseId, untilLessonId = null) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
let maxLessonNumber = null;
if (untilLessonId) {
const untilLesson = await VocabCourseLesson.findOne({
where: {
id: untilLessonId,
courseId: course.id
},
attributes: ['lessonNumber']
});
if (!untilLesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
maxLessonNumber = untilLesson.lessonNumber;
}
const completedProgress = await VocabCourseProgress.findAll({
where: {
userId: user.id,
courseId: course.id,
completed: true
},
attributes: ['lessonId']
});
const completedLessonIds = completedProgress.map((entry) => entry.lessonId);
if (completedLessonIds.length === 0) {
return { courseId: course.id, vocabs: [] };
}
const lessonWhere = {
id: {
[Op.in]: completedLessonIds
},
courseId: course.id
};
if (maxLessonNumber != null) {
lessonWhere.lessonNumber = {
[Op.lte]: maxLessonNumber
};
}
const lessons = await VocabCourseLesson.findAll({
where: lessonWhere,
attributes: ['id', 'speakingPrompts', 'practicalTasks', 'corePatterns'],
order: [['lessonNumber', 'ASC']]
});
const lessonIds = lessons.map((lesson) => lesson.id);
if (!lessonIds.length) {
return { courseId: course.id, vocabs: [] };
}
const exercises = await VocabGrammarExercise.findAll({
where: {
lessonId: {
[Op.in]: lessonIds
}
},
include: [
{
model: VocabGrammarExerciseType,
as: 'exerciseType'
}
],
order: [['lessonId', 'ASC'], ['exerciseNumber', 'ASC']]
});
const extractedFromExercises = this._extractTrainerVocabsFromExercises(exercises.map((exercise) => exercise.get({ plain: true })));
const fallbackVocabs = lessons.flatMap((lesson) =>
this._extractTrainerVocabsFromLessonDidactics(lesson.get({ plain: true }))
);
const mergedVocabs = new Map();
[...extractedFromExercises, ...fallbackVocabs].forEach((entry) => {
if (!entry?.learning || !entry?.reference) return;
mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry);
});
return {
courseId: course.id,
vocabs: Array.from(mergedVocabs.values())
};
}
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);

View File

@@ -110,7 +110,7 @@ export default {
components: { DialogWidget },
data() {
return {
openParams: null, // { languageId, chapterId }
openParams: null, // { languageId, chapterId, lessonId, courseId }
onClose: null,
loading: false,
allVocabs: false,
@@ -168,12 +168,12 @@ export default {
},
},
methods: {
open({ languageId, chapterId, onClose = null }) {
open({ languageId, chapterId, lessonId, courseId, onClose = null }) {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
this.openParams = { languageId, chapterId };
this.openParams = { languageId, chapterId, lessonId, courseId };
this.onClose = typeof onClose === 'function' ? onClose : null;
this.allVocabs = false;
this.simpleMode = false;
@@ -231,7 +231,7 @@ export default {
},
resetQuestion() {
this.current = null;
this.direction = Math.random() < 0.5 ? 'L2R' : 'R2L';
this.direction = this.openParams?.lessonId ? 'L2R' : (Math.random() < 0.5 ? 'L2R' : 'R2L');
this.acceptableAnswers = [];
this.choiceOptions = [];
this.typedAnswer = '';
@@ -272,7 +272,19 @@ export default {
this.loading = true;
try {
let res;
if (this.allVocabs) {
if (this.openParams.lessonId) {
if (this.allVocabs && this.openParams.courseId) {
res = await apiClient.get(`/api/vocab/courses/${this.openParams.courseId}/completed-lesson-vocabs`, {
params: {
untilLessonId: this.openParams.lessonId
}
});
this.pool = res.data?.vocabs || [];
} else {
res = await apiClient.get(`/api/vocab/lessons/${this.openParams.lessonId}/vocab-pool`);
this.pool = res.data?.vocabs || [];
}
} else if (this.allVocabs) {
res = await apiClient.get(`/api/vocab/languages/${this.openParams.languageId}/vocabs`);
this.pool = res.data?.vocabs || [];
} else {
@@ -530,5 +542,3 @@ export default {
font-weight: bold;
}
</style>

View File

@@ -88,6 +88,13 @@
>
{{ getLessonProgress(lesson.id)?.completed ? $t('socialnetwork.vocab.courses.review') : $t('socialnetwork.vocab.courses.start') }}
</button>
<button
v-if="getLessonProgress(lesson.id)?.completed"
@click="openLessonPractice(lesson)"
class="btn-edit"
>
Im Trainer üben
</button>
<button v-if="isOwner" @click="editLesson(lesson.id)" class="btn-edit">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
<button v-if="isOwner" @click="deleteLesson(lesson.id)" class="btn-delete">{{ $t('general.delete') }}</button>
</div>
@@ -99,6 +106,8 @@
</div>
</div>
<VocabPracticeDialog ref="practiceDialog" />
<!-- Add Lesson Dialog -->
<div v-if="showAddLessonDialog" class="dialog-overlay" @click="showAddLessonDialog = false">
<div class="dialog" @click.stop>
@@ -138,9 +147,11 @@
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
import { confirmAction, showApiError, showInfo, showSuccess } from '@/utils/feedback.js';
import VocabPracticeDialog from '@/dialogues/socialnetwork/VocabPracticeDialog.vue';
export default {
name: 'VocabCourseView',
components: { VocabPracticeDialog },
props: {
courseId: {
type: String,
@@ -325,6 +336,12 @@ export default {
openLesson(lessonId) {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
},
openLessonPractice(lesson) {
this.$refs.practiceDialog?.open?.({
courseId: this.courseId,
lessonId: lesson.id
});
},
openLessonAssistant(lessonId) {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}?assistant=1`);
},