From c3b2c60362e890918bd71bbdc4245ce11ce6a170 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 2 Apr 2026 08:25:56 +0200 Subject: [PATCH] feat(vocab): implement user vocab lesson progress reset functionality - 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. --- backend/controllers/adminController.js | 20 ++ backend/controllers/vocabController.js | 1 + backend/routers/adminRouter.js | 1 + backend/routers/vocabRouter.js | 1 + .../scripts/apply-bisaya-course-refresh.js | 10 +- .../scripts/create-bisaya-course-content.js | 38 +++- backend/scripts/create-bisaya-course.js | 16 +- backend/scripts/update-bisaya-didactics.js | 10 +- .../scripts/update-family-words-exercises.js | 20 +- backend/services/adminService.js | 16 ++ backend/services/vocabService.js | 60 ++++++ docs/BISAYA_COURSE_EXAMPLE.md | 2 + frontend/src/i18n/locales/ceb/admin.json | 14 ++ .../src/i18n/locales/ceb/socialnetwork.json | 6 +- frontend/src/i18n/locales/de/admin.json | 14 ++ .../src/i18n/locales/de/socialnetwork.json | 4 + frontend/src/i18n/locales/en/admin.json | 14 ++ .../src/i18n/locales/en/socialnetwork.json | 4 + frontend/src/i18n/locales/es/admin.json | 14 ++ .../src/i18n/locales/es/socialnetwork.json | 4 + frontend/src/views/admin/UsersView.vue | 200 +++++++++++++++++- frontend/src/views/social/VocabLessonView.vue | 72 ++++++- 22 files changed, 517 insertions(+), 24 deletions(-) diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index b7d821b..78c3818 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -33,6 +33,7 @@ class AdminController { this.getUser = this.getUser.bind(this); this.getUsers = this.getUsers.bind(this); this.updateUser = this.updateUser.bind(this); + this.resetUserVocabLessonProgress = this.resetUserVocabLessonProgress.bind(this); this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this); this.setAdultVerificationStatus = this.setAdultVerificationStatus.bind(this); this.getAdultVerificationDocument = this.getAdultVerificationDocument.bind(this); @@ -129,6 +130,25 @@ class AdminController { } } + async resetUserVocabLessonProgress(req, res) { + const schema = Joi.object({ + lessonId: 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.adminResetUserVocabLessonProgress(requester, id, value.lessonId); + res.status(200).json(result); + } catch (err) { + const status = err.message === 'noaccess' ? 403 : (err.message === 'lessonnotfound' ? 404 : 500); + res.status(status).json({ error: err.message }); + } + } + async getAdultVerificationRequests(req, res) { try { const { userid: requester } = req.headers; diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js index 2b8f800..44322dd 100644 --- a/backend/controllers/vocabController.js +++ b/backend/controllers/vocabController.js @@ -52,6 +52,7 @@ class VocabController { // Progress this.getCourseProgress = this._wrapWithUser((userId, req) => this.service.getCourseProgress(userId, req.params.courseId)); this.updateLessonProgress = this._wrapWithUser((userId, req) => this.service.updateLessonProgress(userId, req.params.lessonId, req.body)); + this.resetLessonProgress = this._wrapWithUser((userId, req) => this.service.resetMyLessonProgress(userId, req.params.lessonId)); // Grammar Exercises this.getExerciseTypes = this._wrapWithUser((userId) => this.service.getExerciseTypes()); diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index 5a7a25b..cba3a54 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -25,6 +25,7 @@ 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.post('/users/:id/vocab-lesson-progress/reset', authenticate, adminController.resetUserVocabLessonProgress); router.get('/users/:id', authenticate, adminController.getUser); router.put('/users/:id', authenticate, adminController.updateUser); diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js index ab5da22..35b3a67 100644 --- a/backend/routers/vocabRouter.js +++ b/backend/routers/vocabRouter.js @@ -48,6 +48,7 @@ router.delete('/courses/:courseId/enroll', vocabController.unenrollFromCourse); router.get('/courses/:courseId/progress', vocabController.getCourseProgress); router.get('/lessons/:lessonId', vocabController.getLesson); router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress); +router.delete('/lessons/:lessonId/progress', vocabController.resetLessonProgress); // Grammar Exercises router.get('/grammar/exercise-types', vocabController.getExerciseTypes); diff --git a/backend/scripts/apply-bisaya-course-refresh.js b/backend/scripts/apply-bisaya-course-refresh.js index f3676ad..5e38589 100644 --- a/backend/scripts/apply-bisaya-course-refresh.js +++ b/backend/scripts/apply-bisaya-course-refresh.js @@ -69,17 +69,19 @@ const LESSON_DIDACTICS = { { target: 'Si Tatay.', gloss: 'Das ist Papa.' }, { target: 'Si Kuya nako.', gloss: 'Das ist mein älterer Bruder.' }, { target: 'Si Ate nako.', gloss: 'Das ist meine ältere Schwester.' }, + { target: 'Si Dodong nako.', gloss: 'Das ist mein jüngerer Bruder.' }, + { target: 'Si Inday nako.', gloss: 'Das ist meine jüngere Schwester.' }, { target: 'Si Lola nako.', gloss: 'Das ist meine Großmutter.' }, { target: 'Si Lolo nako.', gloss: 'Das ist mein Großvater.' } ], grammarFocus: [ - { title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' }, + { title: 'Respekt in Familienanreden', text: 'Kuya und Ate richtest du an ältere Geschwister (oder respektvoll an andere). Dodong und Inday nutzt du für jüngere Brüder bzw. Schwestern; „Ading“ ist eine weiche Anrede an jüngere Geschwister.', example: 'Kuya, palihug. / Si Dodong nako.' }, { title: 'si als Personenmarker', text: 'Mit "si" markierst du im einfachen Satz eine konkrete Person.', example: 'Si Nanay. Si Tatay.' } ], speakingPrompts: [ - { title: 'Meine Familie', prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.', cue: 'Si Nanay. Si Tatay. Si Lola nako. Si Kuya nako.' } + { title: 'Meine Familie', prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.', cue: 'Si Nanay. Si Tatay. Si Kuya nako. Si Dodong nako.' } ], - practicalTasks: [{ title: 'Familienpraxis', text: 'Nenne laut sechs Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' }] + practicalTasks: [{ title: 'Familienpraxis', text: 'Nenne laut die acht Kern-Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' }] }, 'Überlebenssätze - Teil 1': { learningGoals: [ @@ -116,6 +118,8 @@ const LESSON_DIDACTICS = { { target: 'Asa si Tatay?', gloss: 'Wo ist Papa?' }, { target: 'Naa siya sa balay.', gloss: 'Er ist zu Hause.' }, { target: 'Kumusta na ang Kuya?', gloss: 'Wie geht es dem älteren Bruder?' }, + { target: 'Kumusta na ang Dodong?', gloss: 'Wie geht es dem jüngeren Bruder?' }, + { target: 'Kumusta na ang Inday?', gloss: 'Wie geht es der jüngeren Schwester?' }, { target: 'Gutom na ko, Nanay.', gloss: 'Ich habe Hunger, Mama.' }, { target: 'Hapit na ang pagkaon.', gloss: 'Das Essen ist fast fertig.' } ], diff --git a/backend/scripts/create-bisaya-course-content.js b/backend/scripts/create-bisaya-course-content.js index f79d7ba..4fe654e 100644 --- a/backend/scripts/create-bisaya-course-content.js +++ b/backend/scripts/create-bisaya-course-content.js @@ -770,6 +770,36 @@ const BISAYA_EXERCISES = { }, explanation: '"Ate" bedeutet "ältere Schwester" auf Bisaya. Wird auch für respektvolle Anrede von älteren Frauen verwendet.' }, + { + exerciseTypeId: 2, // multiple_choice + title: 'Wie sagt man "jüngerer Bruder" auf Bisaya?', + instruction: 'Wähle die richtige Übersetzung.', + questionData: { + type: 'multiple_choice', + question: 'Wie sagt man "jüngerer Bruder" auf Bisaya?', + options: ['Dodong', 'Inday', 'Kuya', 'Ate'] + }, + answerData: { + type: 'multiple_choice', + correctAnswer: 0 + }, + explanation: '"Dodong" ist die gängige Bisaya-Bezeichnung für einen jüngeren Bruder (von älteren Geschwistern aus gesehen).' + }, + { + exerciseTypeId: 2, // multiple_choice + title: 'Wie sagt man "jüngere Schwester" auf Bisaya?', + instruction: 'Wähle die richtige Übersetzung.', + questionData: { + type: 'multiple_choice', + question: 'Wie sagt man "jüngere Schwester" auf Bisaya?', + options: ['Inday', 'Dodong', 'Ate', 'Kuya'] + }, + answerData: { + type: 'multiple_choice', + correctAnswer: 0 + }, + explanation: '"Inday" ist die gängige Bisaya-Bezeichnung für eine jüngere Schwester (von älteren Geschwistern aus gesehen).' + }, { exerciseTypeId: 2, // multiple_choice title: 'Wie sagt man "Großmutter" auf Bisaya?', @@ -806,14 +836,14 @@ const BISAYA_EXERCISES = { instruction: 'Fülle die Lücken mit den richtigen Bisaya-Familienwörtern.', questionData: { type: 'gap_fill', - text: '{gap} (Mutter) | {gap} (Vater) | {gap} (älterer Bruder) | {gap} (ältere Schwester) | {gap} (Großmutter) | {gap} (Großvater)', - gaps: 6 + text: '{gap} (Mutter) | {gap} (Vater) | {gap} (älterer Bruder) | {gap} (ältere Schwester) | {gap} (jüngerer Bruder) | {gap} (jüngere Schwester) | {gap} (Großmutter) | {gap} (Großvater)', + gaps: 8 }, answerData: { type: 'gap_fill', - answers: ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Lola', 'Lolo'] + answers: ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Dodong', 'Inday', 'Lola', 'Lolo'] }, - explanation: 'Nanay = Mutter, Tatay = Vater, Kuya = älterer Bruder, Ate = ältere Schwester, Lola = Großmutter, Lolo = Großvater.' + explanation: 'Nanay = Mutter, Tatay = Vater, Kuya = älterer Bruder, Ate = ältere Schwester, Dodong = jüngerer Bruder, Inday = jüngere Schwester, Lola = Großmutter, Lolo = Großvater.' }, { exerciseTypeId: 4, // transformation diff --git a/backend/scripts/create-bisaya-course.js b/backend/scripts/create-bisaya-course.js index ad143e9..f3aa7c6 100755 --- a/backend/scripts/create-bisaya-course.js +++ b/backend/scripts/create-bisaya-course.js @@ -117,14 +117,16 @@ const LESSON_DIDACTICS = { { target: 'Si Tatay.', gloss: 'Das ist Papa.' }, { target: 'Si Kuya nako.', gloss: 'Das ist mein älterer Bruder.' }, { target: 'Si Ate nako.', gloss: 'Das ist meine ältere Schwester.' }, + { target: 'Si Dodong nako.', gloss: 'Das ist mein jüngerer Bruder.' }, + { target: 'Si Inday nako.', gloss: 'Das ist meine jüngere Schwester.' }, { target: 'Si Lola nako.', gloss: 'Das ist meine Großmutter.' }, { target: 'Si Lolo nako.', gloss: 'Das ist mein Großvater.' } ], grammarFocus: [ { title: 'Respekt in Familienanreden', - text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', - example: 'Kuya, palihug.' + text: 'Kuya und Ate richtest du an ältere Geschwister (oder respektvoll an andere). Dodong und Inday nutzt du für jüngere Brüder bzw. Schwestern; „Ading“ ist eine weiche Anrede an jüngere Geschwister.', + example: 'Kuya, palihug. / Si Dodong nako.' }, { title: 'si als Personenmarker', @@ -136,13 +138,13 @@ const LESSON_DIDACTICS = { { title: 'Meine Familie', prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.', - cue: 'Si Nanay. Si Tatay. Si Lola nako. Si Kuya nako.' + cue: 'Si Nanay. Si Tatay. Si Kuya nako. Si Dodong nako.' } ], practicalTasks: [ { title: 'Familienpraxis', - text: 'Nenne laut sechs Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' + text: 'Nenne laut die acht Kern-Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' } ] }, @@ -202,6 +204,8 @@ const LESSON_DIDACTICS = { { target: 'Asa si Tatay?', gloss: 'Wo ist Papa?' }, { target: 'Naa siya sa balay.', gloss: 'Er ist zu Hause.' }, { target: 'Kumusta na ang Kuya?', gloss: 'Wie geht es dem älteren Bruder?' }, + { target: 'Kumusta na ang Dodong?', gloss: 'Wie geht es dem jüngeren Bruder?' }, + { target: 'Kumusta na ang Inday?', gloss: 'Wie geht es der jüngeren Schwester?' }, { target: 'Gutom na ko, Nanay.', gloss: 'Ich habe Hunger, Mama.' }, { target: 'Hapit na ang pagkaon.', gloss: 'Das Essen ist fast fertig.' } ], @@ -463,9 +467,9 @@ const LESSONS = [ cultural: 'Diese Sätze helfen dir sofort im Alltag weiter.' }, { week: 1, day: 2, num: 3, type: 'vocab', title: 'Familienwörter', - desc: 'Mama, Papa, Kuya, Ate, Lola, Lolo und mehr', + desc: 'Mama, Papa, Kuya, Ate, Dodong, Inday, Lola, Lolo', targetMin: 20, targetScore: 85, review: true, - cultural: 'Kuya und Ate werden auch für Nicht-Verwandte verwendet – sehr respektvoll!' }, + cultural: 'Kuya und Ate für Ältere; Dodong und Inday für jüngere Geschwister. Kuya/Ate werden auch respektvoll außerhalb der Familie genutzt.' }, { week: 1, day: 2, num: 4, type: 'conversation', title: 'Familien-Gespräche', desc: 'Einfache Gespräche mit Familienmitgliedern', diff --git a/backend/scripts/update-bisaya-didactics.js b/backend/scripts/update-bisaya-didactics.js index 3948ab4..f0d667f 100644 --- a/backend/scripts/update-bisaya-didactics.js +++ b/backend/scripts/update-bisaya-didactics.js @@ -67,18 +67,20 @@ const LESSON_DIDACTICS = { { target: 'Si Tatay.', gloss: 'Das ist Papa.' }, { target: 'Si Kuya nako.', gloss: 'Das ist mein älterer Bruder.' }, { target: 'Si Ate nako.', gloss: 'Das ist meine ältere Schwester.' }, + { target: 'Si Dodong nako.', gloss: 'Das ist mein jüngerer Bruder.' }, + { target: 'Si Inday nako.', gloss: 'Das ist meine jüngere Schwester.' }, { target: 'Si Lola nako.', gloss: 'Das ist meine Großmutter.' }, { target: 'Si Lolo nako.', gloss: 'Das ist mein Großvater.' } ], grammarFocus: [ - { title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' }, + { title: 'Respekt in Familienanreden', text: 'Kuya und Ate richtest du an ältere Geschwister (oder respektvoll an andere). Dodong und Inday nutzt du für jüngere Brüder bzw. Schwestern; „Ading“ ist eine weiche Anrede an jüngere Geschwister.', example: 'Kuya, palihug. / Si Dodong nako.' }, { title: 'si als Personenmarker', text: 'Mit "si" markierst du im einfachen Satz eine konkrete Person.', example: 'Si Nanay. Si Tatay.' } ], speakingPrompts: [ - { title: 'Meine Familie', prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.', cue: 'Si Nanay. Si Tatay. Si Lola nako. Si Kuya nako.' } + { title: 'Meine Familie', prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.', cue: 'Si Nanay. Si Tatay. Si Kuya nako. Si Dodong nako.' } ], practicalTasks: [ - { title: 'Familienpraxis', text: 'Nenne laut sechs Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' } + { title: 'Familienpraxis', text: 'Nenne laut die acht Kern-Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' } ] }, 'Überlebenssätze - Teil 1': { @@ -118,6 +120,8 @@ const LESSON_DIDACTICS = { { target: 'Asa si Tatay?', gloss: 'Wo ist Papa?' }, { target: 'Naa siya sa balay.', gloss: 'Er ist zu Hause.' }, { target: 'Kumusta na ang Kuya?', gloss: 'Wie geht es dem älteren Bruder?' }, + { target: 'Kumusta na ang Dodong?', gloss: 'Wie geht es dem jüngeren Bruder?' }, + { target: 'Kumusta na ang Inday?', gloss: 'Wie geht es der jüngeren Schwester?' }, { target: 'Gutom na ko, Nanay.', gloss: 'Ich habe Hunger, Mama.' }, { target: 'Hapit na ang pagkaon.', gloss: 'Das Essen ist fast fertig.' } ], diff --git a/backend/scripts/update-family-words-exercises.js b/backend/scripts/update-family-words-exercises.js index 2451d59..e95c470 100755 --- a/backend/scripts/update-family-words-exercises.js +++ b/backend/scripts/update-family-words-exercises.js @@ -48,6 +48,22 @@ const FAMILY_WORDS = { it: 'sorella maggiore', pt: 'irmã mais velha' }, + 'jüngerer Bruder': { + de: 'jüngerer Bruder', + en: 'younger brother', + es: 'hermano menor', + fr: 'frère cadet', + it: 'fratello minore', + pt: 'irmão mais novo' + }, + 'jüngere Schwester': { + de: 'jüngere Schwester', + en: 'younger sister', + es: 'hermana menor', + fr: 'sœur cadette', + it: 'sorella minore', + pt: 'irmã mais nova' + }, Großmutter: { de: 'Großmutter', en: 'Grandmother', @@ -72,6 +88,8 @@ const BISAYA_TRANSLATIONS = { 'Vater': 'Tatay', 'älterer Bruder': 'Kuya', 'ältere Schwester': 'Ate', + 'jüngerer Bruder': 'Dodong', + 'jüngere Schwester': 'Inday', 'Großmutter': 'Lola', 'Großvater': 'Lolo' }; @@ -98,7 +116,7 @@ function createFamilyWordsExercises(nativeLanguageName) { // Multiple Choice Übungen für jedes Familienwort const familyWords = Object.keys(FAMILY_WORDS); - const bisayaWords = ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Lola', 'Lolo']; + const bisayaWords = ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Dodong', 'Inday', 'Lola', 'Lolo']; familyWords.forEach((key, index) => { const nativeWord = FAMILY_WORDS[key][langCode]; diff --git a/backend/services/adminService.js b/backend/services/adminService.js index d43d239..1a3c2ee 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -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(); diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 9d07935..116d584 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -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() { diff --git a/docs/BISAYA_COURSE_EXAMPLE.md b/docs/BISAYA_COURSE_EXAMPLE.md index d88eaff..dae7047 100644 --- a/docs/BISAYA_COURSE_EXAMPLE.md +++ b/docs/BISAYA_COURSE_EXAMPLE.md @@ -29,6 +29,8 @@ Dieses Dokument zeigt, wie du einen strukturierten Sprachkurs wie den beschriebe - Papa / Tatay – Vater - Kuya – älterer Bruder - Ate – ältere Schwester + - Dodong – jüngerer Bruder + - Inday – jüngere Schwester - Lola / Lolo – Oma / Opa - **Kulturelle Notizen:** Kuya und Ate werden auch für Nicht-Verwandte verwendet – sehr respektvoll! diff --git a/frontend/src/i18n/locales/ceb/admin.json b/frontend/src/i18n/locales/ceb/admin.json index 7370512..a8ea9d7 100644 --- a/frontend/src/i18n/locales/ceb/admin.json +++ b/frontend/src/i18n/locales/ceb/admin.json @@ -21,6 +21,20 @@ "actions": "Mga aksyon", "search": "Pangita" }, + "vocabLessonReset": { + "title": "Kurso sa pinulongan: pag-uswag sa leksiyon", + "intro": "Tangtanga ang pag-uswag, mga resulta sa ehersisyo ug natipig nga kahimtang sa usa ka leksiyon lamang (dili ang tibuok kurso). Makita ra ang mga kurso nga makita sa imong admin account (publiko o imoha).", + "loadCourses": "Ikarga ang mga kurso", + "selectCourse": "Kurso", + "selectLesson": "Leksiyon", + "reset": "I-reset ang leksiyon niining user", + "confirm": "Tinuod nga tangtangon ang pag-uswag sa leksiyon nga «{lesson}» ni {username}?", + "success": "Na-reset na ang pag-uswag sa leksiyon.", + "error": "Dili ma-reset.", + "pickUserFirst": "Una pagpili ug user.", + "noCourses": "Walay nakarga nga kurso o walay makita nga kurso.", + "loadingLessons": "Nagkarga sa mga leksiyon …" + }, "rights": { "add": "Idugang ang katungod", "select": "Palihog pagpili", diff --git a/frontend/src/i18n/locales/ceb/socialnetwork.json b/frontend/src/i18n/locales/ceb/socialnetwork.json index 0ddf9e8..03948e1 100644 --- a/frontend/src/i18n/locales/ceb/socialnetwork.json +++ b/frontend/src/i18n/locales/ceb/socialnetwork.json @@ -381,7 +381,11 @@ "lessonReviewHintNextDue": "Sunod nga petsa: {due}.", "reviewTimeNow": "karon", "reviewTimeTomorrow": "ugma", - "reviewTimeInDays": "sulod sa {count} ka adlaw" + "reviewTimeInDays": "sulod sa {count} ka adlaw", + "resetLessonProgress": "I-reset ang leksiyon", + "resetLessonProgressConfirm": "I-reset ang pag-uswag niining leksiyona? Mawala ang natipig nga kahimtang, mga resulta sa ehersisyo ug sa trainer. Ang ubang leksiyon dili maapektuhan.", + "resetLessonProgressSuccess": "Na-reset na ang pag-uswag sa leksiyon.", + "resetLessonProgressError": "Dili ma-reset ang leksiyon." } } } diff --git a/frontend/src/i18n/locales/de/admin.json b/frontend/src/i18n/locales/de/admin.json index 7eedd29..5392395 100644 --- a/frontend/src/i18n/locales/de/admin.json +++ b/frontend/src/i18n/locales/de/admin.json @@ -30,6 +30,20 @@ "actions": "Aktionen", "search": "Suchen" }, + "vocabLessonReset": { + "title": "Sprachkurs: Lektionsfortschritt", + "intro": "Fortschritt, Übungsergebnisse und gespeicherter Lektionszustand für eine einzelne Lektion löschen (nicht der ganze Kurs). Es werden nur Kurse gelistet, die du als Admin sehen kannst (öffentlich oder eigene).", + "loadCourses": "Kurse laden", + "selectCourse": "Kurs", + "selectLesson": "Lektion", + "reset": "Lektion für diesen Nutzer zurücksetzen", + "confirm": "Fortschritt der Lektion „{lesson}“ für {username} wirklich löschen?", + "success": "Lektionsfortschritt wurde zurückgesetzt.", + "error": "Zurücksetzen fehlgeschlagen.", + "pickUserFirst": "Zuerst einen Benutzer auswählen.", + "noCourses": "Keine Kurse geladen oder keine sichtbaren Kurse.", + "loadingLessons": "Lektionen werden geladen …" + }, "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/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index f6d2a93..7dc9481 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -487,6 +487,10 @@ "score": "Punktzahl", "review": "Wiederholen", "start": "Starten", + "resetLessonProgress": "Lektion zurücksetzen", + "resetLessonProgressConfirm": "Fortschritt dieser Lektion zurücksetzen? Gespeicherter Stand, Übungsergebnisse und Trainer-Zustand werden gelöscht. Andere Lektionen bleiben unverändert.", + "resetLessonProgressSuccess": "Die Lektion wurde zurückgesetzt.", + "resetLessonProgressError": "Die Lektion konnte nicht zurückgesetzt werden.", "noLessons": "Dieser Kurs hat noch keine Lektionen.", "lessonNumber": "Lektionsnummer", "chapter": "Kapitel", diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index 987bb50..5a90fec 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -30,6 +30,20 @@ "actions": "Actions", "search": "Search" }, + "vocabLessonReset": { + "title": "Language course: lesson progress", + "intro": "Delete progress, exercise results and saved lesson state for a single lesson (not the whole course). Only courses you can see as this admin account are listed (public or your own).", + "loadCourses": "Load courses", + "selectCourse": "Course", + "selectLesson": "Lesson", + "reset": "Reset lesson for this user", + "confirm": "Really delete progress for lesson “{lesson}” for {username}?", + "success": "Lesson progress was reset.", + "error": "Reset failed.", + "pickUserFirst": "Select a user first.", + "noCourses": "No courses loaded or no visible courses.", + "loadingLessons": "Loading lessons…" + }, "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/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index 7767069..2548457 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -487,6 +487,10 @@ "score": "Score", "review": "Review", "start": "Start", + "resetLessonProgress": "Reset lesson", + "resetLessonProgressConfirm": "Reset progress for this lesson? Saved state, exercise results, and trainer progress will be cleared. Other lessons stay unchanged.", + "resetLessonProgressSuccess": "Lesson progress was reset.", + "resetLessonProgressError": "Could not reset the lesson.", "noLessons": "This course has no lessons yet.", "lessonNumber": "Lesson Number", "chapter": "Chapter", diff --git a/frontend/src/i18n/locales/es/admin.json b/frontend/src/i18n/locales/es/admin.json index 479bf88..d1c8faf 100644 --- a/frontend/src/i18n/locales/es/admin.json +++ b/frontend/src/i18n/locales/es/admin.json @@ -30,6 +30,20 @@ "actions": "Acciones", "search": "Buscar" }, + "vocabLessonReset": { + "title": "Curso de idiomas: progreso de lección", + "intro": "Elimina el progreso, los resultados de ejercicios y el estado guardado de una sola lección (no todo el curso). Solo se listan cursos visibles para tu cuenta de administración (públicos o propios).", + "loadCourses": "Cargar cursos", + "selectCourse": "Curso", + "selectLesson": "Lección", + "reset": "Restablecer lección para este usuario", + "confirm": "¿Borrar de verdad el progreso de la lección «{lesson}» para {username}?", + "success": "Se restableció el progreso de la lección.", + "error": "No se pudo restablecer.", + "pickUserFirst": "Primero elige un usuario.", + "noCourses": "No hay cursos cargados o no hay cursos visibles.", + "loadingLessons": "Cargando lecciones…" + }, "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/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json index 99aa1a4..313f5ed 100644 --- a/frontend/src/i18n/locales/es/socialnetwork.json +++ b/frontend/src/i18n/locales/es/socialnetwork.json @@ -485,6 +485,10 @@ "score": "Puntuación", "review": "Repasar", "start": "Empezar", + "resetLessonProgress": "Restablecer lección", + "resetLessonProgressConfirm": "¿Restablecer el progreso de esta lección? Se borrarán el estado guardado, los resultados de ejercicios y el progreso del entrenador. Las demás lecciones no cambian.", + "resetLessonProgressSuccess": "Se restableció el progreso de la lección.", + "resetLessonProgressError": "No se pudo restablecer la lección.", "noLessons": "Este curso aún no tiene lecciones.", "lessonNumber": "Número de lección", "chapter": "Capítulo", diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 731b062..2dba86a 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -30,6 +30,56 @@ + +
+

{{ $t('admin.vocabLessonReset.title') }}

+

{{ $t('admin.vocabLessonReset.intro') }}

+ +
+ +
+ + + +

+ {{ $t('admin.vocabLessonReset.noCourses') }} +

+ + + +
+ +
+
@@ -43,15 +93,31 @@ export default { data() { return { selected: null, - form: { username: '', active: true } + form: { username: '', active: true }, + vocabCourses: [], + vocabCoursesAttempted: false, + selectedVocabCourseId: '', + vocabCourseLessons: [], + selectedVocabLessonId: '', + loadingVocabCourses: false, + loadingVocabCourseDetail: false, + vocabResetSubmitting: false }; }, methods: { + clearVocabResetUi() { + this.vocabCourses = []; + this.vocabCoursesAttempted = false; + this.selectedVocabCourseId = ''; + this.vocabCourseLessons = []; + this.selectedVocabLessonId = ''; + }, async select(u) { const res = await apiClient.get(`/api/admin/users/${u.id}`); this.selected = res.data; this.form.username = res.data.username; this.form.active = !!res.data.active; + this.clearVocabResetUi(); }, toggleBlocked(e) { this.form.active = !e.target.checked; @@ -63,6 +129,83 @@ export default { active: this.form.active }); this.$root?.$refs?.messageDialog?.open?.('tr:common.saved'); + }, + async loadVocabCourses() { + if (!this.selected) { + this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.pickUserFirst')); + return; + } + this.loadingVocabCourses = true; + this.vocabCoursesAttempted = true; + try { + const { data } = await apiClient.get('/api/vocab/courses'); + this.vocabCourses = Array.isArray(data) ? data : []; + this.selectedVocabCourseId = ''; + this.vocabCourseLessons = []; + this.selectedVocabLessonId = ''; + } catch (e) { + console.error('[UsersView] vocab courses:', e); + this.vocabCourses = []; + this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error')); + } finally { + this.loadingVocabCourses = false; + } + }, + async onVocabCourseChange() { + this.selectedVocabLessonId = ''; + this.vocabCourseLessons = []; + if (!this.selectedVocabCourseId) { + return; + } + this.loadingVocabCourseDetail = true; + try { + const { data } = await apiClient.get(`/api/vocab/courses/${this.selectedVocabCourseId}`); + const lessons = data?.lessons || []; + this.vocabCourseLessons = [...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 || 0) - (b.lessonNumber || 0); + }); + } catch (e) { + console.error('[UsersView] vocab course detail:', e); + this.vocabCourseLessons = []; + this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error')); + } finally { + this.loadingVocabCourseDetail = false; + } + }, + adminResetVocabLesson() { + if (!this.selected || !this.selectedVocabLessonId || this.vocabResetSubmitting) { + return; + } + const lesson = this.vocabCourseLessons.find((l) => String(l.id) === String(this.selectedVocabLessonId)); + const lessonLabel = lesson ? `${lesson.lessonNumber}. ${lesson.title}` : this.selectedVocabLessonId; + const msg = this.$t('admin.vocabLessonReset.confirm', { + lesson: lessonLabel, + username: this.selected.username + }); + if (!window.confirm(msg)) { + return; + } + this.runAdminVocabReset(); + }, + async runAdminVocabReset() { + this.vocabResetSubmitting = true; + try { + await apiClient.post(`/api/admin/users/${this.selected.id}/vocab-lesson-progress/reset`, { + lessonId: Number(this.selectedVocabLessonId) + }); + this.$root?.$refs?.messageDialog?.open?.('tr:admin.vocabLessonReset.success'); + } catch (e) { + console.error('[UsersView] admin vocab reset:', e); + this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error')); + } finally { + this.vocabResetSubmitting = false; + } } } }; @@ -76,7 +219,8 @@ export default { .admin-users__hero, .admin-users__search, -.edit { +.edit, +.vocab-reset { background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95)); border: 1px solid var(--color-border); border-radius: var(--radius-lg); @@ -85,7 +229,8 @@ export default { .admin-users__hero, .admin-users__search, -.edit { +.edit, +.vocab-reset { padding: 22px 24px; } @@ -113,6 +258,40 @@ export default { max-width: 560px; } +.vocab-reset { + display: grid; + gap: 14px; + max-width: 560px; +} + +.vocab-reset__title { + margin: 0; + font-size: 1.15rem; +} + +.vocab-reset__intro, +.vocab-reset__hint { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.92rem; + line-height: 1.45; +} + +.vocab-reset__row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.vocab-reset__btn-secondary { + padding: 8px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md, 6px); + background: rgba(255, 255, 255, 0.9); + cursor: pointer; + font-weight: 600; +} + .edit__header { display: flex; align-items: center; @@ -141,6 +320,17 @@ export default { color: var(--color-text-secondary); } +.edit__field input, +.edit__field select { + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md, 6px); + background: #fff; +} + .edit__toggle { display: inline-flex; align-items: center; @@ -156,7 +346,8 @@ export default { @media (max-width: 760px) { .admin-users__hero, .admin-users__search, - .edit { + .edit, + .vocab-reset { padding: 18px; } @@ -170,4 +361,3 @@ export default { } } - diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index 276db2d..89a8602 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -5,6 +5,14 @@

{{ lesson.title }}

+
@@ -942,7 +950,8 @@ export default { lessonStatePersistenceReady: false, lessonStateSaveTimer: null, lessonStateSaveInFlight: false, - pendingLessonStatePayload: null + pendingLessonStatePayload: null, + resettingLessonProgress: false }; }, computed: { @@ -1373,6 +1382,44 @@ export default { const userId = this.user?.id || 'guest'; return `vocab-lesson-state:${LESSON_STATE_VERSION}:${userId}:${this.courseId}:${this.lessonId}`; }, + clearLocalLessonStateCache() { + const storageKey = this.getLessonStateStorageKey(); + if (!storageKey) { + return; + } + try { + window.localStorage.removeItem(storageKey); + } catch (error) { + console.warn('[VocabLessonView] Lokaler Lektions-Cache konnte nicht gelöscht werden:', error); + } + }, + confirmResetLessonProgress() { + if (!this.lessonId || this.resettingLessonProgress) { + return; + } + if (!window.confirm(this.$t('socialnetwork.vocab.courses.resetLessonProgressConfirm'))) { + return; + } + this.resetLessonProgressOnServer(); + }, + async resetLessonProgressOnServer() { + if (!this.lessonId || this.resettingLessonProgress) { + return; + } + this.resettingLessonProgress = true; + try { + await apiClient.delete(`/api/vocab/lessons/${this.lessonId}/progress`); + this.clearLocalLessonStateCache(); + this.$root?.$refs?.messageDialog?.open?.('tr:socialnetwork.vocab.courses.resetLessonProgressSuccess'); + await this.loadLesson(); + } catch (e) { + console.error('[VocabLessonView] Lektion zurücksetzen fehlgeschlagen:', e); + const msg = e?.response?.data?.error || this.$t('socialnetwork.vocab.courses.resetLessonProgressError'); + this.$root?.$refs?.messageDialog?.open?.(msg); + } finally { + this.resettingLessonProgress = false; + } + }, buildPersistedLessonState() { return { version: LESSON_STATE_VERSION, @@ -3104,6 +3151,29 @@ export default { margin-bottom: 20px; } +.lesson-header h2 { + flex: 1; + min-width: 0; + margin: 0; +} + +.btn-reset-lesson { + flex-shrink: 0; + padding: 8px 14px; + border: 1px solid rgba(140, 90, 60, 0.35); + border-radius: 4px; + background: rgba(255, 248, 240, 0.95); + color: #6b4420; + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; +} + +.btn-reset-lesson:disabled { + opacity: 0.65; + cursor: not-allowed; +} + .lesson-overview-card { display: flex; flex-direction: column;