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() {

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -82,6 +82,31 @@
{{ vocabResetSubmitting ? $t('general.loading') : $t('admin.vocabLessonReset.reset') }}
</button>
</div>
<template v-if="vocabCourseLessons.length">
<p class="vocab-reset__divider">{{ $t('admin.vocabLessonMarkComplete.divider') }}</p>
<label class="edit__field">
<span>{{ $t('admin.vocabLessonMarkComplete.throughLabel') }}</span>
<input
v-model.number="vocabMarkThroughNumber"
type="number"
min="1"
:max="vocabMaxLessonNumber"
:disabled="loadingVocabCourseDetail || vocabMarkSubmitting"
class="vocab-reset__number"
/>
</label>
<p class="vocab-reset__hint">{{ $t('admin.vocabLessonMarkComplete.hint') }}</p>
<div class="vocab-reset__row">
<button
type="button"
:disabled="!canMarkVocabThrough || vocabMarkSubmitting || loadingVocabCourseDetail"
@click="adminMarkVocabLessonsThrough"
>
{{ vocabMarkSubmitting ? $t('general.loading') : $t('admin.vocabLessonMarkComplete.submit') }}
</button>
</div>
</template>
</section>
</div>
</template>
@@ -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;