feat(admin): add user vocab course management functionality
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user