feat(vocab): implement user vocab lesson progress reset functionality
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s

- Added `resetUserVocabLessonProgress` method in `AdminController` to allow admins to reset a user's progress for a specific vocab lesson.
- Introduced corresponding route in `adminRouter` for the new reset functionality.
- Enhanced `VocabService` with methods to purge lesson progress for users, ensuring that only the specified lesson's progress is affected.
- Updated UI components in `UsersView` to facilitate the selection of courses and lessons for resetting progress, including confirmation dialogs and loading states.
- Added localization support for the new reset functionality across multiple languages.
- Implemented reset functionality in `VocabLessonView` for users to reset their own lesson progress.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 08:25:56 +02:00
parent 13534498fa
commit c3b2c60362
22 changed files with 517 additions and 24 deletions

View File

@@ -34,6 +34,7 @@ import RelationshipType from "../models/falukant/type/relationship.js";
import RelationshipState from "../models/falukant/data/relationship_state.js";
import { sequelize } from '../utils/sequelize.js';
import npcCreationJobService from './npcCreationJobService.js';
import VocabService from './vocabService.js';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
import path from 'path';
@@ -1957,6 +1958,21 @@ class AdminService {
}
return job;
}
async adminResetUserVocabLessonProgress(requesterHashedId, targetHashedId, lessonId) {
if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) {
throw new Error('noaccess');
}
const vocab = new VocabService();
try {
return await vocab.adminResetLessonProgressForUser(targetHashedId, Number(lessonId));
} catch (e) {
if (e.status === 404) {
throw new Error('lessonnotfound');
}
throw e;
}
}
}
export default new AdminService();

View File

@@ -2485,6 +2485,66 @@ export default class VocabService {
return this._serializeLessonProgress(progress, lessonData);
}
/**
* Löscht nur den Fortschritt zu einer Lektion (Zeile vocab_course_progress + zugehörige grammar-exercise-progress).
* Gesamtkurs / andere Lektionen bleiben unberührt.
*/
async _purgeLessonProgressForUser(userId, lessonId) {
const numericLessonId = Number(lessonId);
const exercises = await VocabGrammarExercise.findAll({
where: { lessonId: numericLessonId },
attributes: ['id']
});
const exerciseIds = exercises.map((e) => e.id);
let deletedExerciseProgressRows = 0;
if (exerciseIds.length > 0) {
deletedExerciseProgressRows = await VocabGrammarExerciseProgress.destroy({
where: { userId, exerciseId: { [Op.in]: exerciseIds } }
});
}
const deletedLessonProgressRows = await VocabCourseProgress.destroy({
where: { userId, lessonId: numericLessonId }
});
return {
success: true,
lessonId: numericLessonId,
deletedLessonProgressRows,
deletedExerciseProgressRows
};
}
/** Eingeloggter Nutzer setzt eigene Lektion zurück (nur bei Kurs-Einschreibung). */
async resetMyLessonProgress(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
return this._purgeLessonProgressForUser(user.id, lesson.id);
}
/** Admin: Zielnutzer per Hash, ohne Einschreibungszwang (idempotentes Löschen). */
async adminResetLessonProgressForUser(targetHashedUserId, lessonId) {
const user = await this._getUserByHashedId(targetHashedUserId);
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
return this._purgeLessonProgressForUser(user.id, lesson.id);
}
// ========== GRAMMAR EXERCISE METHODS ==========
async getExerciseTypes() {