diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 636bf44..7ac8974 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -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; diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index 4038679..80a5dd2 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -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); diff --git a/backend/services/adminService.js b/backend/services/adminService.js index 4fa64c1..129c7b6 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -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'); diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 9299b80..db74357 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -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() { diff --git a/frontend/src/i18n/locales/ceb/admin.json b/frontend/src/i18n/locales/ceb/admin.json index b739c0e..6a57e72 100644 --- a/frontend/src/i18n/locales/ceb/admin.json +++ b/frontend/src/i18n/locales/ceb/admin.json @@ -36,6 +36,16 @@ "loadCoursesError": "Dili makarga ang lista sa mga kurso.", "loadingLessons": "Nagkarga sa mga leksiyon …" }, + "vocabLessonMarkComplete": { + "divider": "Ayuhon ang pag-uswag (dili paghimo og peke nga resulta sa ehersisyo)", + "throughLabel": "Tanang leksiyon hangtod sa numero (lakip)", + "hint": "I-mark ang kulang o abli nga mga row nga nahuman, lakip ang target score ug unang review wave. Ang nahuman na dili usbon.", + "submit": "I-mark nga nahuman hangtod dinhi", + "confirm": "I-mark nga nahuman ang tanang leksiyon nga numero ≤ {n} ni {username} niining kurso?", + "success": "{marked} ka leksiyon nga bag-ong gi-mark nga nahuman ({unchanged} klaro nang nahuman).", + "successNone": "Walay pagbag-o: tanang leksiyon nga naapektuhan ({unchanged}) klaro nang nahuman.", + "error": "Dili ma-mark nga nahuman." + }, "rights": { "add": "Idugang ang katungod", "select": "Palihog pagpili", diff --git a/frontend/src/i18n/locales/de/admin.json b/frontend/src/i18n/locales/de/admin.json index a3b251b..e648295 100644 --- a/frontend/src/i18n/locales/de/admin.json +++ b/frontend/src/i18n/locales/de/admin.json @@ -45,6 +45,16 @@ "loadCoursesError": "Die Kursliste konnte nicht geladen werden.", "loadingLessons": "Lektionen werden geladen …" }, + "vocabLessonMarkComplete": { + "divider": "Fortschritt reparieren (ohne Übungsergebnisse zu fälschen)", + "throughLabel": "Alle Lektionen bis Lektionsnummer (einschließlich)", + "hint": "Setzt fehlende oder offene Einträge auf „abgeschlossen“, inkl. Ziel-Score und erster Review-Welle. Bereits abgeschlossene Lektionen bleiben unverändert.", + "submit": "Bis hier als abgeschlossen markieren", + "confirm": "Alle Lektionen mit Nummer ≤ {n} für {username} in diesem Kurs als abgeschlossen markieren?", + "success": "{marked} Lektion(en) neu als abgeschlossen gesetzt ({unchanged} waren bereits erledigt).", + "successNone": "Keine Änderung: alle betroffenen Lektionen ({unchanged}) waren bereits abgeschlossen.", + "error": "Markieren fehlgeschlagen." + }, "adultVerification": { "title": "[Admin] - Erotik-Freigaben", "intro": "Volljährige Nutzer können den Erotikbereich beantragen. Hier werden Anfragen geprüft und freigegeben oder abgelehnt.", diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index ac2bf2f..205721e 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -45,6 +45,16 @@ "loadCoursesError": "Could not load the course list.", "loadingLessons": "Loading lessons…" }, + "vocabLessonMarkComplete": { + "divider": "Repair progress (does not fabricate exercise answers)", + "throughLabel": "All lessons up to and including lesson number", + "hint": "Marks missing or open rows as completed, including target score and first review wave. Already completed lessons are left unchanged.", + "submit": "Mark through here as completed", + "confirm": "Mark every lesson with number ≤ {n} for {username} in this course as completed?", + "success": "{marked} lesson(s) newly marked complete ({unchanged} were already done).", + "successNone": "No change: all affected lessons ({unchanged}) were already completed.", + "error": "Could not mark lessons complete." + }, "adultVerification": { "title": "[Admin] - Erotic approvals", "intro": "Adult users can request access to the erotic area. Requests can be reviewed, approved or rejected here.", diff --git a/frontend/src/i18n/locales/es/admin.json b/frontend/src/i18n/locales/es/admin.json index f81bf04..c6761fe 100644 --- a/frontend/src/i18n/locales/es/admin.json +++ b/frontend/src/i18n/locales/es/admin.json @@ -45,6 +45,16 @@ "loadCoursesError": "No se pudo cargar la lista de cursos.", "loadingLessons": "Cargando lecciones…" }, + "vocabLessonMarkComplete": { + "divider": "Reparar progreso (no inventa resultados de ejercicios)", + "throughLabel": "Todas las lecciones hasta el número (incluido)", + "hint": "Marca filas faltantes o abiertas como completadas, con puntuación objetivo y primera ola de repaso. Las ya completadas no se cambian.", + "submit": "Marcar hasta aquí como completadas", + "confirm": "¿Marcar todas las lecciones con número ≤ {n} para {username} en este curso como completadas?", + "success": "{marked} lección(es) marcadas como completadas ({unchanged} ya estaban hechas).", + "successNone": "Sin cambios: todas las lecciones afectadas ({unchanged}) ya estaban completadas.", + "error": "No se pudo marcar como completadas." + }, "adultVerification": { "title": "[Admin] - Aprobaciones eróticas", "intro": "Los usuarios adultos pueden solicitar acceso al área erótica. Aquí se revisan, aprueban o rechazan las solicitudes.", diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index ec06409..8b48632 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -82,6 +82,31 @@ {{ vocabResetSubmitting ? $t('general.loading') : $t('admin.vocabLessonReset.reset') }} + + @@ -105,6 +130,8 @@ export default { loadingVocabCourses: false, loadingVocabCourseDetail: false, vocabResetSubmitting: false, + vocabMarkThroughNumber: null, + vocabMarkSubmitting: false, vocabLoadError: false }; }, @@ -125,6 +152,15 @@ export default { selectLabel: dup ? `${c.title} (#${c.id})` : (c.title || `#${c.id}`) }; }); + }, + vocabMaxLessonNumber() { + const list = this.vocabCourseLessons; + if (!list.length) return 1; + return Math.max(...list.map((l) => Number(l.lessonNumber) || 0)); + }, + canMarkVocabThrough() { + const n = Number(this.vocabMarkThroughNumber); + return Number.isFinite(n) && n >= 1 && n <= this.vocabMaxLessonNumber; } }, methods: { @@ -135,6 +171,7 @@ export default { this.selectedVocabCourseId = ''; this.vocabCourseLessons = []; this.selectedVocabLessonId = ''; + this.vocabMarkThroughNumber = null; }, async select(u) { const res = await apiClient.get(`/api/admin/users/${u.id}`); @@ -168,6 +205,7 @@ export default { this.selectedVocabCourseId = ''; this.vocabCourseLessons = []; this.selectedVocabLessonId = ''; + this.vocabMarkThroughNumber = null; } catch (e) { console.error('[UsersView] vocab courses:', e); this.vocabCourses = []; @@ -179,6 +217,7 @@ export default { }, async onVocabCourseChange() { this.selectedVocabLessonId = ''; + this.vocabMarkThroughNumber = null; this.vocabCourseLessons = []; if (!this.selectedVocabCourseId) { return; @@ -232,6 +271,45 @@ export default { } finally { this.vocabResetSubmitting = false; } + }, + adminMarkVocabLessonsThrough() { + if (!this.selected || !this.selectedVocabCourseId || !this.canMarkVocabThrough || this.vocabMarkSubmitting) { + return; + } + const n = Number(this.vocabMarkThroughNumber); + const msg = this.$t('admin.vocabLessonMarkComplete.confirm', { + n, + username: this.selected.username + }); + if (!window.confirm(msg)) { + return; + } + this.runAdminVocabMarkThrough(); + }, + async runAdminVocabMarkThrough() { + this.vocabMarkSubmitting = true; + try { + const { data } = await apiClient.post( + `/api/admin/users/${this.selected.id}/vocab-lesson-progress/mark-complete-through`, + { + courseId: Number(this.selectedVocabCourseId), + throughLessonNumber: Number(this.vocabMarkThroughNumber) + } + ); + const details = Array.isArray(data?.details) ? data.details : []; + const marked = details.filter((d) => d.status === 'marked_complete').length; + const unchanged = details.filter((d) => d.status === 'unchanged').length; + const msgKey = + marked === 0 && unchanged > 0 + ? 'admin.vocabLessonMarkComplete.successNone' + : 'admin.vocabLessonMarkComplete.success'; + this.$root?.$refs?.messageDialog?.open?.(this.$t(msgKey, { marked, unchanged })); + } catch (e) { + console.error('[UsersView] admin vocab mark complete:', e); + this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonMarkComplete.error')); + } finally { + this.vocabMarkSubmitting = false; + } } } }; @@ -303,6 +381,18 @@ export default { line-height: 1.45; } +.vocab-reset__divider { + margin: 8px 0 0; + padding-top: 14px; + border-top: 1px dashed var(--color-border); + color: var(--color-text-secondary); + font-size: 0.88rem; +} + +.vocab-reset__number { + max-width: 120px; +} + .vocab-reset__row { display: flex; flex-wrap: wrap;