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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user