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