extended admin tool for finished lessons
All checks were successful
Deploy to production / deploy (push) Successful in 2m54s

This commit is contained in:
Torsten Schulz (local)
2026-04-02 13:32:13 +02:00
parent 9d663e4f2b
commit edbf22ac5b
9 changed files with 281 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ class AdminController {
this.getUsers = this.getUsers.bind(this);
this.updateUser = this.updateUser.bind(this);
this.resetUserVocabLessonProgress = this.resetUserVocabLessonProgress.bind(this);
this.markUserVocabLessonsCompleteThrough = this.markUserVocabLessonsCompleteThrough.bind(this);
this.getUserVocabCourses = this.getUserVocabCourses.bind(this);
this.getVocabCourseForAdmin = this.getVocabCourseForAdmin.bind(this);
this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this);
@@ -151,6 +152,34 @@ class AdminController {
}
}
async markUserVocabLessonsCompleteThrough(req, res) {
const schema = Joi.object({
courseId: Joi.number().integer().positive().required(),
throughLessonNumber: Joi.number().integer().positive().required()
});
const { error, value } = schema.validate(req.body || {});
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
try {
const { userid: requester } = req.headers;
const { id } = req.params;
const result = await AdminService.adminMarkUserVocabLessonsCompleteThrough(
requester,
id,
value.courseId,
value.throughLessonNumber
);
res.status(200).json(result);
} catch (err) {
let status = 500;
if (err.message === 'noaccess') status = 403;
else if (err.message === 'notenrolled') status = 403;
else if (err.message === 'badrequest') status = 400;
res.status(status).json({ error: err.message });
}
}
async getUserVocabCourses(req, res) {
try {
const { userid: requester } = req.headers;

View File

@@ -27,6 +27,11 @@ router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adm
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.post(
'/users/:id/vocab-lesson-progress/mark-complete-through',
authenticate,
adminController.markUserVocabLessonsCompleteThrough
);
router.get('/vocab/courses/:courseId', authenticate, adminController.getVocabCourseForAdmin);
router.get('/users/:id', authenticate, adminController.getUser);
router.put('/users/:id', authenticate, adminController.updateUser);

View File

@@ -1974,6 +1974,28 @@ class AdminService {
}
}
async adminMarkUserVocabLessonsCompleteThrough(requesterHashedId, targetHashedId, courseId, throughLessonNumber) {
if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) {
throw new Error('noaccess');
}
const vocab = new VocabService();
try {
return await vocab.adminMarkLessonsCompleteThrough(
targetHashedId,
Number(courseId),
Number(throughLessonNumber)
);
} catch (e) {
if (e.status === 403) {
throw new Error('notenrolled');
}
if (e.status === 400) {
throw new Error('badrequest');
}
throw e;
}
}
async adminListUserEnrolledVocabCourses(requesterHashedId, targetHashedId) {
if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) {
throw new Error('noaccess');

View File

@@ -2609,6 +2609,101 @@ export default class VocabService {
return this._purgeLessonProgressForUser(user.id, lesson.id);
}
/**
* Admin: Alle Lektionen eines Kurses bis einschließlich lesson_number als abgeschlossen setzen
* (nur Zeilen, die noch nicht completed sind). Nur bei eingeschriebenem Nutzer.
*/
async adminMarkLessonsCompleteThrough(targetHashedUserId, courseId, throughLessonNumber) {
const user = await this._getUserByHashedId(targetHashedUserId);
const cid = Number(courseId);
const maxNum = Number(throughLessonNumber);
if (!Number.isFinite(cid) || cid < 1) {
const err = new Error('Invalid courseId');
err.status = 400;
throw err;
}
if (!Number.isFinite(maxNum) || maxNum < 1) {
const err = new Error('Invalid throughLessonNumber');
err.status = 400;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: cid }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const lessons = await VocabCourseLesson.findAll({
where: { courseId: cid, lessonNumber: { [Op.lte]: maxNum } },
order: [['lessonNumber', 'ASC']]
});
const now = new Date();
const details = [];
for (const lesson of lessons) {
const lessonData = lesson.get({ plain: true });
const targetScore = lessonData.targetScorePercent || 80;
const [progress] = await VocabCourseProgress.findOrCreate({
where: { userId: user.id, lessonId: lesson.id },
defaults: {
userId: user.id,
courseId: cid,
lessonId: lesson.id,
completed: false,
score: 0,
lessonState: {}
}
});
if (progress.completed) {
details.push({
lessonNumber: lesson.lessonNumber,
lessonId: lesson.id,
status: 'unchanged'
});
continue;
}
const mergedState = this._applyScheduledReviewState(
this._sanitizeLessonState(progress.lessonState),
{
previousCompleted: false,
nextCompleted: true,
shouldAdvanceReview: true,
lessonData,
now
}
);
await progress.update({
completed: true,
completedAt: now,
score: Math.max(Number(progress.score) || 0, targetScore),
lastAccessedAt: now,
lessonState: mergedState
});
details.push({
lessonNumber: lesson.lessonNumber,
lessonId: lesson.id,
status: 'marked_complete'
});
}
return {
courseId: cid,
throughLessonNumber: maxNum,
lessonsConsidered: lessons.length,
details
};
}
// ========== GRAMMAR EXERCISE METHODS ==========
async getExerciseTypes() {