Files
yourpart3/backend/services/vocabService.js
Torsten Schulz (local) 09e53244d9 Add native language support in vocab course management
- Introduced a new field for native language in the VocabCourse model to allow learners to specify their native language.
- Updated the VocabService to handle native language during course creation and retrieval, including filtering options.
- Enhanced the database schema to include foreign key constraints for native language.
- Updated frontend components to support native language selection and display in course listings.
- Added internationalization strings for native language features in both German and English.
2026-01-19 11:43:38 +01:00

1301 lines
40 KiB
JavaScript

import crypto from 'crypto';
import User from '../models/community/user.js';
import VocabCourse from '../models/community/vocab_course.js';
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import VocabCourseEnrollment from '../models/community/vocab_course_enrollment.js';
import VocabCourseProgress from '../models/community/vocab_course_progress.js';
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
import { sequelize } from '../utils/sequelize.js';
import { notifyUser } from '../utils/socket.js';
import { Op } from 'sequelize';
export default class VocabService {
async _getUserByHashedId(hashedUserId) {
const user = await User.findOne({ where: { hashedId: hashedUserId } });
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
return user;
}
_normalizeLexeme(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
async _getLanguageAccess(userId, languageId) {
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid language id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
l.id,
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_language l
WHERE l.id = :languageId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId, languageId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Language not found or no access');
err.status = 404;
throw err;
}
return row;
}
async _getChapterAccess(userId, chapterId) {
const id = Number.parseInt(chapterId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid chapter id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
c.id,
c.language_id AS "languageId",
c.title,
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_chapter c
JOIN community.vocab_language l ON l.id = c.language_id
WHERE c.id = :chapterId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId, chapterId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Chapter not found or no access');
err.status = 404;
throw err;
}
return row;
}
async listLanguages(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const rows = await sequelize.query(
`
SELECT
l.id,
l.name,
l.share_code AS "shareCode",
TRUE AS "isOwner"
FROM community.vocab_language l
WHERE l.owner_user_id = :userId
UNION ALL
SELECT
l.id,
l.name,
NULL::text AS "shareCode",
FALSE AS "isOwner"
FROM community.vocab_language_subscription s
JOIN community.vocab_language l ON l.id = s.language_id
WHERE s.user_id = :userId
ORDER BY name ASC
`,
{
replacements: { userId: user.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { languages: rows };
}
async listLanguagesForMenu(userId) {
// userId ist die numerische community.user.id
const rows = await sequelize.query(
`
SELECT l.id, l.name
FROM community.vocab_language l
WHERE l.owner_user_id = :userId
UNION
SELECT l.id, l.name
FROM community.vocab_language_subscription s
JOIN community.vocab_language l ON l.id = s.language_id
WHERE s.user_id = :userId
ORDER BY name ASC
`,
{
replacements: { userId },
type: sequelize.QueryTypes.SELECT,
}
);
return rows;
}
async createLanguage(hashedUserId, { name }) {
const user = await this._getUserByHashedId(hashedUserId);
const cleanName = typeof name === 'string' ? name.trim() : '';
if (!cleanName || cleanName.length < 2 || cleanName.length > 60) {
const err = new Error('Invalid language name');
err.status = 400;
throw err;
}
// 16 hex chars => ausreichend kurz, gut teilbar
const shareCode = crypto.randomBytes(8).toString('hex');
const [created] = await sequelize.query(
`
INSERT INTO community.vocab_language (owner_user_id, name, share_code)
VALUES (:ownerUserId, :name, :shareCode)
RETURNING id, name, share_code AS "shareCode"
`,
{
replacements: { ownerUserId: user.id, name: cleanName, shareCode },
type: sequelize.QueryTypes.SELECT,
}
);
// Menü dynamisch nachladen (bei allen offenen Tabs/Clients)
try {
notifyUser(user.hashedId, 'reloadmenu', {});
} catch (_) {}
return created;
}
async subscribeByShareCode(hashedUserId, { shareCode }) {
const user = await this._getUserByHashedId(hashedUserId);
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
if (!code || code.length < 6 || code.length > 128) {
const err = new Error('Invalid share code');
err.status = 400;
throw err;
}
const [lang] = await sequelize.query(
`
SELECT id, owner_user_id AS "ownerUserId", name
FROM community.vocab_language
WHERE share_code = :shareCode
LIMIT 1
`,
{
replacements: { shareCode: code },
type: sequelize.QueryTypes.SELECT,
}
);
if (!lang) {
const err = new Error('Language not found');
err.status = 404;
throw err;
}
// Owner braucht kein Abo
if (lang.ownerUserId === user.id) {
return { subscribed: false, message: 'Already owner', languageId: lang.id };
}
await sequelize.query(
`
INSERT INTO community.vocab_language_subscription (user_id, language_id)
VALUES (:userId, :languageId)
ON CONFLICT (user_id, language_id) DO NOTHING
`,
{
replacements: { userId: user.id, languageId: lang.id },
type: sequelize.QueryTypes.INSERT,
}
);
try {
notifyUser(user.hashedId, 'reloadmenu', {});
} catch (_) {}
return { subscribed: true, languageId: lang.id, name: lang.name };
}
async getLanguage(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid language id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
l.id,
l.name,
CASE WHEN l.owner_user_id = :userId THEN l.share_code ELSE NULL END AS "shareCode",
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_language l
WHERE l.id = :languageId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId: user.id, languageId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Language not found or no access');
err.status = 404;
throw err;
}
return row;
}
async listChapters(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const rows = await sequelize.query(
`
SELECT
c.id,
c.title,
c.created_at AS "createdAt",
(
SELECT COUNT(*)
FROM community.vocab_chapter_lexeme cl
WHERE cl.chapter_id = c.id
)::int AS "vocabCount"
FROM community.vocab_chapter c
WHERE c.language_id = :languageId
ORDER BY c.title ASC
`,
{
replacements: { languageId: access.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { chapters: rows, isOwner: access.isOwner };
}
async createChapter(hashedUserId, languageId, { title }) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
if (!access.isOwner) {
const err = new Error('Only owner can create chapters');
err.status = 403;
throw err;
}
const cleanTitle = typeof title === 'string' ? title.trim() : '';
if (!cleanTitle || cleanTitle.length < 2 || cleanTitle.length > 80) {
const err = new Error('Invalid chapter title');
err.status = 400;
throw err;
}
const [created] = await sequelize.query(
`
INSERT INTO community.vocab_chapter (language_id, title, created_by_user_id)
VALUES (:languageId, :title, :userId)
RETURNING id, title, created_at AS "createdAt"
`,
{
replacements: { languageId: access.id, title: cleanTitle, userId: user.id },
type: sequelize.QueryTypes.SELECT,
}
);
return created;
}
async getChapter(hashedUserId, chapterId) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
return { id: ch.id, languageId: ch.languageId, title: ch.title, isOwner: ch.isOwner };
}
async listChapterVocabs(hashedUserId, chapterId) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
const rows = await sequelize.query(
`
SELECT
cl.id,
l1.text AS "learning",
l2.text AS "reference",
cl.created_at AS "createdAt"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE cl.chapter_id = :chapterId
ORDER BY l1.text ASC, l2.text ASC
`,
{
replacements: { chapterId: ch.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { chapter: { id: ch.id, title: ch.title, languageId: ch.languageId, isOwner: ch.isOwner }, vocabs: rows };
}
async listLanguageVocabs(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "reference",
cl.created_at AS "createdAt"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
`,
{
replacements: { languageId: access.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
}
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const query = typeof q === 'string' ? q.trim() : '';
// Abwärtskompatibel: falls alte Parameter genutzt werden, zusammenfassen
const learningTerm = typeof learning === 'string' ? learning.trim() : '';
const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : '';
const effective = query || learningTerm || motherTerm;
if (!effective) {
const err = new Error('Missing search term');
err.status = 400;
throw err;
}
const like = `%${effective}%`;
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "motherTongue"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
AND (l1.text ILIKE :like OR l2.text ILIKE :like)
ORDER BY l2.text ASC, l1.text ASC, c.title ASC
LIMIT 200
`,
{
replacements: {
languageId: access.id,
like,
},
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, results: rows };
}
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
if (!ch.isOwner) {
const err = new Error('Only owner can add vocab');
err.status = 403;
throw err;
}
const learningText = typeof learning === 'string' ? learning.trim() : '';
const referenceText = typeof reference === 'string' ? reference.trim() : '';
if (!learningText || !referenceText) {
const err = new Error('Invalid vocab');
err.status = 400;
throw err;
}
const learningNorm = this._normalizeLexeme(learningText);
const referenceNorm = this._normalizeLexeme(referenceText);
// Transaktion: Lexeme upserten + Zuordnung setzen
return await sequelize.transaction(async (t) => {
const [learningLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: learningText, normalized: learningNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [referenceLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: referenceText, normalized: referenceNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [mapping] = await sequelize.query(
`
INSERT INTO community.vocab_chapter_lexeme (chapter_id, learning_lexeme_id, reference_lexeme_id, created_by_user_id)
VALUES (:chapterId, :learningId, :referenceId, :userId)
ON CONFLICT (chapter_id, learning_lexeme_id, reference_lexeme_id) DO NOTHING
RETURNING id
`,
{
replacements: {
chapterId: ch.id,
learningId: learningLex.id,
referenceId: referenceLex.id,
userId: user.id,
},
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
return { created: Boolean(mapping?.id) };
});
}
// ========== COURSE METHODS ==========
async createCourse(hashedUserId, { title, description, languageId, nativeLanguageId, difficultyLevel = 1, isPublic = false }) {
const user = await this._getUserByHashedId(hashedUserId);
// Prüfe Zugriff auf Sprache
await this._getLanguageAccess(user.id, languageId);
const shareCode = isPublic ? crypto.randomBytes(8).toString('hex') : null;
const course = await VocabCourse.create({
ownerUserId: user.id,
title,
description,
languageId: Number(languageId),
nativeLanguageId: nativeLanguageId ? Number(nativeLanguageId) : null,
difficultyLevel: Number(difficultyLevel) || 1,
isPublic: Boolean(isPublic),
shareCode
});
return course.get({ plain: true });
}
async getCourses(hashedUserId, { includePublic = true, includeOwn = true, languageId, nativeLanguageId, search } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const where = {};
const andConditions = [];
// Zugriffsbedingungen
if (includeOwn && includePublic) {
andConditions.push({
[Op.or]: [
{ ownerUserId: user.id },
{ isPublic: true }
]
});
} else if (includeOwn) {
where.ownerUserId = user.id;
} else if (includePublic) {
where.isPublic = true;
}
// Filter nach Zielsprache (die zu lernende Sprache)
if (languageId) {
where.languageId = Number(languageId);
}
// Filter nach Muttersprache (die Sprache des Lerners)
if (nativeLanguageId !== undefined) {
if (nativeLanguageId === null) {
// NULL bedeutet "für alle Sprachen" - zeige Kurse ohne native_language_id
where.nativeLanguageId = null;
} else {
where.nativeLanguageId = Number(nativeLanguageId);
}
}
// Suche nach Titel oder Beschreibung
if (search && search.trim()) {
const searchTerm = `%${search.trim()}%`;
andConditions.push({
[Op.or]: [
{ title: { [Op.iLike]: searchTerm } },
{ description: { [Op.iLike]: searchTerm } }
]
});
}
// Kombiniere alle AND-Bedingungen
if (andConditions.length > 0) {
where[Op.and] = andConditions;
}
const courses = await VocabCourse.findAll({
where,
order: [['createdAt', 'DESC']]
});
const coursesData = courses.map(c => c.get({ plain: true }));
// Lade Sprachnamen für alle Kurse
const languageIds = [...new Set(coursesData.map(c => c.languageId))];
if (languageIds.length > 0) {
const [languages] = await sequelize.query(
`SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`,
{
replacements: { languageIds },
type: sequelize.QueryTypes.SELECT
}
);
const languageMap = new Map(languages.map(l => [l.id, l.name]));
coursesData.forEach(c => {
c.languageName = languageMap.get(c.languageId) || null;
});
}
return coursesData;
}
async getCourseByShareCode(hashedUserId, shareCode) {
const user = await this._getUserByHashedId(hashedUserId);
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
if (!code || code.length < 6 || code.length > 128) {
const err = new Error('Invalid share code');
err.status = 400;
throw err;
}
const course = await VocabCourse.findOne({
where: { shareCode: code }
});
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff (öffentlich oder Besitzer)
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Course is not public');
err.status = 403;
throw err;
}
return course.get({ plain: true });
}
async getCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId, {
include: [
{
model: VocabCourseLesson,
as: 'lessons',
order: [['lessonNumber', 'ASC']]
}
]
});
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const courseData = course.get({ plain: true });
courseData.lessons = courseData.lessons || [];
// Sortiere Lektionen nach Woche, Tag, dann Nummer
courseData.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 - b.lessonNumber;
});
return courseData;
}
async updateCourse(hashedUserId, courseId, { title, description, languageId, nativeLanguageId, difficultyLevel, isPublic }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update the course');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (languageId !== undefined) updates.languageId = Number(languageId);
if (nativeLanguageId !== undefined) updates.nativeLanguageId = nativeLanguageId ? Number(nativeLanguageId) : null;
if (difficultyLevel !== undefined) updates.difficultyLevel = Number(difficultyLevel);
if (isPublic !== undefined) {
updates.isPublic = Boolean(isPublic);
// Generiere Share-Code wenn Kurs öffentlich wird
if (isPublic && !course.shareCode) {
updates.shareCode = crypto.randomBytes(8).toString('hex');
} else if (!isPublic) {
updates.shareCode = null;
}
}
await course.update(updates);
return course.get({ plain: true });
}
async deleteCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete the course');
err.status = 403;
throw err;
}
await course.destroy();
return { success: true };
}
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
if (course.ownerUserId !== user.id) {
const err = new Error('Only the owner can add lessons');
err.status = 403;
throw err;
}
// Prüfe, ob Kapitel zur gleichen Sprache gehört (nur wenn chapterId angegeben)
if (chapterId) {
const [chapter] = await sequelize.query(
`SELECT language_id FROM community.vocab_chapter WHERE id = :chapterId`,
{
replacements: { chapterId: Number(chapterId) },
type: sequelize.QueryTypes.SELECT
}
);
if (!chapter || chapter.language_id !== course.languageId) {
const err = new Error('Chapter does not belong to the course language');
err.status = 400;
throw err;
}
}
const lesson = await VocabCourseLesson.create({
courseId: course.id,
chapterId: chapterId ? Number(chapterId) : null,
lessonNumber: Number(lessonNumber),
title,
description,
weekNumber: weekNumber ? Number(weekNumber) : null,
dayNumber: dayNumber ? Number(dayNumber) : null,
lessonType: lessonType || 'vocab',
audioUrl: audioUrl || null,
culturalNotes: culturalNotes || null,
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
});
return lesson.get({ plain: true });
}
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update lessons');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (lessonNumber !== undefined) updates.lessonNumber = Number(lessonNumber);
if (weekNumber !== undefined) updates.weekNumber = weekNumber ? Number(weekNumber) : null;
if (dayNumber !== undefined) updates.dayNumber = dayNumber ? Number(dayNumber) : null;
if (lessonType !== undefined) updates.lessonType = lessonType;
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
await lesson.update(updates);
return lesson.get({ plain: true });
}
async deleteLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete lessons');
err.status = 403;
throw err;
}
await lesson.destroy();
return { success: true };
}
async enrollInCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
if (!course) {
const err = new Error('Course not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (course.ownerUserId !== user.id && !course.isPublic) {
const err = new Error('Course is not public');
err.status = 403;
throw err;
}
const [enrollment, created] = await VocabCourseEnrollment.findOrCreate({
where: { userId: user.id, courseId: course.id },
defaults: { userId: user.id, courseId: course.id }
});
if (!created) {
const err = new Error('Already enrolled in this course');
err.status = 400;
throw err;
}
return enrollment.get({ plain: true });
}
async unenrollFromCourse(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 404;
throw err;
}
await enrollment.destroy();
return { success: true };
}
async getMyCourses(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [{ model: VocabCourse, as: 'course' }],
order: [['enrolledAt', 'DESC']]
});
return enrollments.map(e => ({
...e.course.get({ plain: true }),
enrolledAt: e.enrolledAt
}));
}
async getCourseProgress(hashedUserId, courseId) {
const user = await this._getUserByHashedId(hashedUserId);
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: Number(courseId) }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
const progress = await VocabCourseProgress.findAll({
where: { userId: user.id, courseId: Number(courseId) },
include: [{ model: VocabCourseLesson, as: 'lesson' }],
order: [[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC']]
});
return progress.map(p => p.get({ plain: true }));
}
async updateLessonProgress(hashedUserId, lessonId, { completed, score, timeSpentMinutes }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Einschreibung
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;
}
const lessonData = await VocabCourseLesson.findByPk(lesson.id);
const targetScore = lessonData.targetScorePercent || 80;
const actualScore = Number(score) || 0;
const hasReachedTarget = actualScore >= targetScore;
// Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true)
const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false);
const [progress, created] = await VocabCourseProgress.findOrCreate({
where: { userId: user.id, lessonId: lesson.id },
defaults: {
userId: user.id,
courseId: lesson.courseId,
lessonId: lesson.id,
completed: isCompleted,
score: actualScore,
lastAccessedAt: new Date()
}
});
if (!created) {
const updates = { lastAccessedAt: new Date() };
if (score !== undefined) {
updates.score = Math.max(progress.score, actualScore);
// Prüfe, ob Ziel jetzt erreicht wurde
if (updates.score >= targetScore && !progress.completed) {
if (!lessonData.requiresReview) {
updates.completed = true;
updates.completedAt = new Date();
}
}
}
if (completed !== undefined) {
updates.completed = Boolean(completed);
if (completed && !progress.completedAt) {
updates.completedAt = new Date();
}
}
await progress.update(updates);
} else if (isCompleted) {
progress.completed = true;
progress.completedAt = new Date();
await progress.save();
}
const progressData = progress.get({ plain: true });
progressData.targetScore = targetScore;
progressData.hasReachedTarget = progressData.score >= targetScore;
progressData.needsReview = lessonData.requiresReview && !progressData.hasReachedTarget;
return progressData;
}
// ========== GRAMMAR EXERCISE METHODS ==========
async getExerciseTypes() {
const types = await VocabGrammarExerciseType.findAll({
order: [['name', 'ASC']]
});
return types.map(t => t.get({ plain: true }));
}
async createGrammarExercise(hashedUserId, lessonId, { exerciseTypeId, exerciseNumber, title, instruction, questionData, answerData, explanation }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe, ob User Besitzer des Kurses ist
if (lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can add grammar exercises');
err.status = 403;
throw err;
}
const exercise = await VocabGrammarExercise.create({
lessonId: lesson.id,
exerciseTypeId: Number(exerciseTypeId),
exerciseNumber: Number(exerciseNumber),
title,
instruction,
questionData,
answerData,
explanation,
createdByUserId: user.id
});
return exercise.get({ plain: true });
}
async getGrammarExercisesForLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson) {
const err = new Error('Lesson not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
const exercises = await VocabGrammarExercise.findAll({
where: { lessonId: lesson.id },
include: [{ model: VocabGrammarExerciseType, as: 'exerciseType' }],
order: [['exerciseNumber', 'ASC']]
});
return exercises.map(e => e.get({ plain: true }));
}
async getGrammarExercise(hashedUserId, exerciseId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] },
{ model: VocabGrammarExerciseType, as: 'exerciseType' }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
// Prüfe Zugriff
if (exercise.lesson.course.ownerUserId !== user.id && !exercise.lesson.course.isPublic) {
const err = new Error('Access denied');
err.status = 403;
throw err;
}
return exercise.get({ plain: true });
}
async checkGrammarExerciseAnswer(hashedUserId, exerciseId, userAnswer) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
// Prüfe Einschreibung
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: exercise.lesson.courseId }
});
if (!enrollment) {
const err = new Error('Not enrolled in this course');
err.status = 403;
throw err;
}
// Überprüfe Antwort (vereinfachte Logik - kann je nach Übungstyp erweitert werden)
const isCorrect = this._checkAnswer(exercise.answerData, userAnswer, exercise.exerciseTypeId);
// Speichere Fortschritt
const [progress, created] = await VocabGrammarExerciseProgress.findOrCreate({
where: { userId: user.id, exerciseId: exercise.id },
defaults: {
userId: user.id,
exerciseId: exercise.id,
attempts: 1,
correctAttempts: isCorrect ? 1 : 0,
lastAttemptAt: new Date(),
completed: false
}
});
if (!created) {
progress.attempts += 1;
if (isCorrect) {
progress.correctAttempts += 1;
if (!progress.completed) {
progress.completed = true;
progress.completedAt = new Date();
}
}
progress.lastAttemptAt = new Date();
await progress.save();
} else if (isCorrect) {
progress.completed = true;
progress.completedAt = new Date();
await progress.save();
}
return {
correct: isCorrect,
explanation: exercise.explanation,
progress: progress.get({ plain: true })
};
}
_checkAnswer(answerData, userAnswer, exerciseTypeId) {
// Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden
if (!answerData || !userAnswer) return false;
// Für Multiple Choice: Prüfe ob userAnswer eine der richtigen Antworten ist
if (exerciseTypeId === 2) { // multiple_choice
const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct];
return correctAnswers.includes(userAnswer);
}
// Für Lückentext: Normalisiere und vergleiche
if (exerciseTypeId === 1) { // gap_fill
const normalize = (str) => String(str || '').trim().toLowerCase();
const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct];
const normalizedUserAnswer = normalize(userAnswer);
return correctAnswers.some(correct => normalize(correct) === normalizedUserAnswer);
}
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
const normalize = (str) => String(str || '').trim().toLowerCase();
const correctAnswers = Array.isArray(answerData.correct) ? answerData.correct : [answerData.correct];
return correctAnswers.some(correct => normalize(correct) === normalize(userAnswer));
}
async getGrammarExerciseProgress(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercises = await this.getGrammarExercisesForLesson(hashedUserId, lessonId);
const exerciseIds = exercises.map(e => e.id);
const progress = await VocabGrammarExerciseProgress.findAll({
where: {
userId: user.id,
exerciseId: { [Op.in]: exerciseIds }
}
});
const progressMap = new Map(progress.map(p => [p.exerciseId, p.get({ plain: true })]));
return exercises.map(exercise => ({
...exercise,
progress: progressMap.get(exercise.id) || null
}));
}
async updateGrammarExercise(hashedUserId, exerciseId, { title, instruction, questionData, answerData, explanation, exerciseNumber }) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (exercise.lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can update exercises');
err.status = 403;
throw err;
}
const updates = {};
if (title !== undefined) updates.title = title;
if (instruction !== undefined) updates.instruction = instruction;
if (questionData !== undefined) updates.questionData = questionData;
if (answerData !== undefined) updates.answerData = answerData;
if (explanation !== undefined) updates.explanation = explanation;
if (exerciseNumber !== undefined) updates.exerciseNumber = Number(exerciseNumber);
await exercise.update(updates);
return exercise.get({ plain: true });
}
async deleteGrammarExercise(hashedUserId, exerciseId) {
const user = await this._getUserByHashedId(hashedUserId);
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }
]
});
if (!exercise) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
if (exercise.lesson.course.ownerUserId !== user.id) {
const err = new Error('Only the owner can delete exercises');
err.status = 403;
throw err;
}
await exercise.destroy();
return { success: true };
}
}