From 2272db7f91f871cacc268c45ccaf08729abec863 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 2 Apr 2026 09:21:52 +0200 Subject: [PATCH] feat(admin): add user vocab course management functionality - Implemented `getUserVocabCourses` and `getVocabCourseForAdmin` methods in `AdminController` to allow admins to retrieve enrolled vocab courses for users and specific course details, respectively. - Updated `adminRouter` to include new routes for accessing user vocab courses and course details. - Enhanced `AdminService` with methods to list user-enrolled vocab courses and retrieve course information with lessons, ensuring proper access control. - Improved `VocabService` to support the new functionalities, including attaching language names to course data. - Updated UI components in `UsersView` to reflect changes, including error handling and loading states for course retrieval, along with localization updates for new features. --- backend/controllers/adminController.js | 26 ++++ backend/routers/adminRouter.js | 2 + backend/services/adminService.js | 30 +++++ backend/services/vocabService.js | 162 ++++++++++++++++------- frontend/src/i18n/locales/ceb/admin.json | 7 +- frontend/src/i18n/locales/de/admin.json | 7 +- frontend/src/i18n/locales/en/admin.json | 7 +- frontend/src/i18n/locales/es/admin.json | 7 +- frontend/src/views/admin/UsersView.vue | 36 ++++- 9 files changed, 217 insertions(+), 67 deletions(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 78c3818..636bf44 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -34,6 +34,8 @@ class AdminController { this.getUsers = this.getUsers.bind(this); this.updateUser = this.updateUser.bind(this); this.resetUserVocabLessonProgress = this.resetUserVocabLessonProgress.bind(this); + this.getUserVocabCourses = this.getUserVocabCourses.bind(this); + this.getVocabCourseForAdmin = this.getVocabCourseForAdmin.bind(this); this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this); this.setAdultVerificationStatus = this.setAdultVerificationStatus.bind(this); this.getAdultVerificationDocument = this.getAdultVerificationDocument.bind(this); @@ -149,6 +151,30 @@ class AdminController { } } + async getUserVocabCourses(req, res) { + try { + const { userid: requester } = req.headers; + const { id } = req.params; + const result = await AdminService.adminListUserEnrolledVocabCourses(requester, id); + res.status(200).json(result); + } catch (err) { + const status = err.message === 'noaccess' ? 403 : (err.message === 'notfound' ? 404 : 500); + res.status(status).json({ error: err.message }); + } + } + + async getVocabCourseForAdmin(req, res) { + try { + const { userid: requester } = req.headers; + const { courseId } = req.params; + const result = await AdminService.adminGetVocabCourseWithLessons(requester, courseId); + res.status(200).json(result); + } catch (err) { + const status = err.message === 'noaccess' ? 403 : (err.message === 'coursenotfound' ? 404 : 500); + res.status(status).json({ error: err.message }); + } + } + async getAdultVerificationRequests(req, res) { try { const { userid: requester } = req.headers; diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index cba3a54..4038679 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -25,7 +25,9 @@ router.put('/users/:id/adult-verification', authenticate, adminController.setAdu router.get('/users/erotic-moderation', authenticate, adminController.getEroticModerationReports); router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adminController.getEroticModerationPreview); router.put('/users/erotic-moderation/:id', authenticate, adminController.applyEroticModerationAction); +router.get('/users/:id/vocab-courses', authenticate, adminController.getUserVocabCourses); router.post('/users/:id/vocab-lesson-progress/reset', authenticate, adminController.resetUserVocabLessonProgress); +router.get('/vocab/courses/:courseId', authenticate, adminController.getVocabCourseForAdmin); router.get('/users/:id', authenticate, adminController.getUser); router.put('/users/:id', authenticate, adminController.updateUser); diff --git a/backend/services/adminService.js b/backend/services/adminService.js index 1a3c2ee..4fa64c1 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -1973,6 +1973,36 @@ class AdminService { throw e; } } + + async adminListUserEnrolledVocabCourses(requesterHashedId, targetHashedId) { + if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) { + throw new Error('noaccess'); + } + const vocab = new VocabService(); + try { + return await vocab.listEnrolledVocabCoursesForUser(targetHashedId); + } catch (e) { + if (e.status === 404) { + throw new Error('notfound'); + } + throw e; + } + } + + async adminGetVocabCourseWithLessons(requesterHashedId, courseId) { + if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) { + throw new Error('noaccess'); + } + const vocab = new VocabService(); + try { + return await vocab.adminGetCourseWithLessonsForStaff(Number(courseId)); + } catch (e) { + if (e.status === 404) { + throw new Error('coursenotfound'); + } + throw e; + } + } } export default new AdminService(); diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 116d584..036b999 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -312,6 +312,44 @@ export default class VocabService { 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' } }), @@ -1743,57 +1781,8 @@ export default class VocabService { order: [['createdAt', 'DESC']] }); - // Debug-Logging (kann später entfernt werden) - console.log(`[getCourses] Gefunden: ${courses.length} Kurse`, { - userId: user.id, - languageId, - nativeLanguageId, - search, - whereBefore: JSON.stringify(where, null, 2), - includePublic: includePublicBool, - includeOwn: includeOwnBool, - andConditionsLength: andConditions.length, - directWherePropsBefore: Object.keys(where).filter(key => key !== Op.and && key !== Op.or), - whereAfter: JSON.stringify(where, null, 2) - }); - const coursesData = courses.map(c => c.get({ plain: true })); - - // Lade Sprachnamen für alle Kurse - 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; - }); - } - } - - // Lade Muttersprachen-Namen für alle Kurse - 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; - }); - } - } + await this._attachLanguageNamesToCourseRows(coursesData); return coursesData; } @@ -1875,6 +1864,45 @@ export default class VocabService { 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); @@ -2357,6 +2385,40 @@ export default class VocabService { })); } + /** + * 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); diff --git a/frontend/src/i18n/locales/ceb/admin.json b/frontend/src/i18n/locales/ceb/admin.json index a8ea9d7..b739c0e 100644 --- a/frontend/src/i18n/locales/ceb/admin.json +++ b/frontend/src/i18n/locales/ceb/admin.json @@ -23,8 +23,8 @@ }, "vocabLessonReset": { "title": "Kurso sa pinulongan: pag-uswag sa leksiyon", - "intro": "Tangtanga ang pag-uswag, mga resulta sa ehersisyo ug natipig nga kahimtang sa usa ka leksiyon lamang (dili ang tibuok kurso). Makita ra ang mga kurso nga makita sa imong admin account (publiko o imoha).", - "loadCourses": "Ikarga ang mga kurso", + "intro": "Tangtanga ang pag-uswag, mga resulta sa ehersisyo ug natipig nga kahimtang sa usa ka leksiyon lamang (dili ang tibuok kurso). Makita ra ang mga kurso nga na-enroll niini nga tiggamit.", + "loadCourses": "Ikarga ang na-enroll nga mga kurso", "selectCourse": "Kurso", "selectLesson": "Leksiyon", "reset": "I-reset ang leksiyon niining user", @@ -32,7 +32,8 @@ "success": "Na-reset na ang pag-uswag sa leksiyon.", "error": "Dili ma-reset.", "pickUserFirst": "Una pagpili ug user.", - "noCourses": "Walay nakarga nga kurso o walay makita nga kurso.", + "noEnrolledCourses": "Kini nga tiggamit wala na-enroll sa bisan unsang kurso sa pinulongan.", + "loadCoursesError": "Dili makarga ang lista sa mga kurso.", "loadingLessons": "Nagkarga sa mga leksiyon …" }, "rights": { diff --git a/frontend/src/i18n/locales/de/admin.json b/frontend/src/i18n/locales/de/admin.json index 5392395..a3b251b 100644 --- a/frontend/src/i18n/locales/de/admin.json +++ b/frontend/src/i18n/locales/de/admin.json @@ -32,8 +32,8 @@ }, "vocabLessonReset": { "title": "Sprachkurs: Lektionsfortschritt", - "intro": "Fortschritt, Übungsergebnisse und gespeicherter Lektionszustand für eine einzelne Lektion löschen (nicht der ganze Kurs). Es werden nur Kurse gelistet, die du als Admin sehen kannst (öffentlich oder eigene).", - "loadCourses": "Kurse laden", + "intro": "Fortschritt, Übungsergebnisse und gespeicherter Lektionszustand für eine einzelne Lektion löschen (nicht der ganze Kurs). Es werden nur Sprachkurse gelistet, in die dieser Benutzer eingeschrieben ist.", + "loadCourses": "Eingeschriebene Kurse laden", "selectCourse": "Kurs", "selectLesson": "Lektion", "reset": "Lektion für diesen Nutzer zurücksetzen", @@ -41,7 +41,8 @@ "success": "Lektionsfortschritt wurde zurückgesetzt.", "error": "Zurücksetzen fehlgeschlagen.", "pickUserFirst": "Zuerst einen Benutzer auswählen.", - "noCourses": "Keine Kurse geladen oder keine sichtbaren Kurse.", + "noEnrolledCourses": "Dieser Benutzer ist in keinem Sprachkurs eingeschrieben.", + "loadCoursesError": "Die Kursliste konnte nicht geladen werden.", "loadingLessons": "Lektionen werden geladen …" }, "adultVerification": { diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index 5a90fec..ac2bf2f 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -32,8 +32,8 @@ }, "vocabLessonReset": { "title": "Language course: lesson progress", - "intro": "Delete progress, exercise results and saved lesson state for a single lesson (not the whole course). Only courses you can see as this admin account are listed (public or your own).", - "loadCourses": "Load courses", + "intro": "Delete progress, exercise results and saved lesson state for a single lesson (not the whole course). Only language courses this user is enrolled in are listed.", + "loadCourses": "Load enrolled courses", "selectCourse": "Course", "selectLesson": "Lesson", "reset": "Reset lesson for this user", @@ -41,7 +41,8 @@ "success": "Lesson progress was reset.", "error": "Reset failed.", "pickUserFirst": "Select a user first.", - "noCourses": "No courses loaded or no visible courses.", + "noEnrolledCourses": "This user is not enrolled in any language course.", + "loadCoursesError": "Could not load the course list.", "loadingLessons": "Loading lessons…" }, "adultVerification": { diff --git a/frontend/src/i18n/locales/es/admin.json b/frontend/src/i18n/locales/es/admin.json index d1c8faf..f81bf04 100644 --- a/frontend/src/i18n/locales/es/admin.json +++ b/frontend/src/i18n/locales/es/admin.json @@ -32,8 +32,8 @@ }, "vocabLessonReset": { "title": "Curso de idiomas: progreso de lección", - "intro": "Elimina el progreso, los resultados de ejercicios y el estado guardado de una sola lección (no todo el curso). Solo se listan cursos visibles para tu cuenta de administración (públicos o propios).", - "loadCourses": "Cargar cursos", + "intro": "Elimina el progreso, los resultados de ejercicios y el estado guardado de una sola lección (no todo el curso). Solo aparecen los cursos de idiomas en los que está inscrito este usuario.", + "loadCourses": "Cargar cursos inscritos", "selectCourse": "Curso", "selectLesson": "Lección", "reset": "Restablecer lección para este usuario", @@ -41,7 +41,8 @@ "success": "Se restableció el progreso de la lección.", "error": "No se pudo restablecer.", "pickUserFirst": "Primero elige un usuario.", - "noCourses": "No hay cursos cargados o no hay cursos visibles.", + "noEnrolledCourses": "Este usuario no está inscrito en ningún curso de idiomas.", + "loadCoursesError": "No se pudo cargar la lista de cursos.", "loadingLessons": "Cargando lecciones…" }, "adultVerification": { diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 27686ee..ec06409 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -50,12 +50,15 @@ {{ $t('admin.vocabLessonReset.selectCourse') }} +

+ {{ $t('admin.vocabLessonReset.loadCoursesError') }} +

- {{ $t('admin.vocabLessonReset.noCourses') }} + {{ $t('admin.vocabLessonReset.noEnrolledCourses') }}