feat(admin): add user vocab course management functionality
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s

- 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.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 09:21:52 +02:00
parent b3c8e8e210
commit 2272db7f91
9 changed files with 217 additions and 67 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -50,12 +50,15 @@
<span>{{ $t('admin.vocabLessonReset.selectCourse') }}</span>
<select v-model="selectedVocabCourseId" @change="onVocabCourseChange">
<option value="">{{ $t('admin.vocabLessonReset.selectCourse') }}</option>
<option v-for="c in vocabCourses" :key="c.id" :value="String(c.id)">{{ c.title }}</option>
<option v-for="c in vocabCoursesForSelect" :key="c.id" :value="String(c.id)">{{ c.selectLabel }}</option>
</select>
</label>
<p v-else-if="vocabCoursesAttempted && !loadingVocabCourses && vocabLoadError" class="vocab-reset__hint">
{{ $t('admin.vocabLessonReset.loadCoursesError') }}
</p>
<p v-else-if="vocabCoursesAttempted && !loadingVocabCourses" class="vocab-reset__hint">
{{ $t('admin.vocabLessonReset.noCourses') }}
{{ $t('admin.vocabLessonReset.noEnrolledCourses') }}
</p>
<label v-if="vocabCourseLessons.length" class="edit__field">
@@ -101,13 +104,34 @@ export default {
selectedVocabLessonId: '',
loadingVocabCourses: false,
loadingVocabCourseDetail: false,
vocabResetSubmitting: false
vocabResetSubmitting: false,
vocabLoadError: false
};
},
computed: {
/** Gleicher Titel bei mehreren Kurs-IDs → Kurs-ID anhängen (z. B. geklonte Kurse). */
vocabCoursesForSelect() {
const list = this.vocabCourses;
const counts = {};
list.forEach((c) => {
const t = (c.title || '').trim() || '—';
counts[t] = (counts[t] || 0) + 1;
});
return list.map((c) => {
const t = (c.title || '').trim() || '—';
const dup = counts[t] > 1;
return {
...c,
selectLabel: dup ? `${c.title} (#${c.id})` : (c.title || `#${c.id}`)
};
});
}
},
methods: {
clearVocabResetUi() {
this.vocabCourses = [];
this.vocabCoursesAttempted = false;
this.vocabLoadError = false;
this.selectedVocabCourseId = '';
this.vocabCourseLessons = [];
this.selectedVocabLessonId = '';
@@ -137,8 +161,9 @@ export default {
}
this.loadingVocabCourses = true;
this.vocabCoursesAttempted = true;
this.vocabLoadError = false;
try {
const { data } = await apiClient.get('/api/vocab/courses');
const { data } = await apiClient.get(`/api/admin/users/${this.selected.id}/vocab-courses`);
this.vocabCourses = Array.isArray(data) ? data : [];
this.selectedVocabCourseId = '';
this.vocabCourseLessons = [];
@@ -146,6 +171,7 @@ export default {
} catch (e) {
console.error('[UsersView] vocab courses:', e);
this.vocabCourses = [];
this.vocabLoadError = true;
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
} finally {
this.loadingVocabCourses = false;
@@ -159,7 +185,7 @@ export default {
}
this.loadingVocabCourseDetail = true;
try {
const { data } = await apiClient.get(`/api/vocab/courses/${this.selectedVocabCourseId}`);
const { data } = await apiClient.get(`/api/admin/vocab/courses/${this.selectedVocabCourseId}`);
const lessons = data?.lessons || [];
this.vocabCourseLessons = [...lessons].sort((a, b) => {
if (a.weekNumber !== b.weekNumber) {