- Updated VocabService to support multiple correct answers in multiple choice exercises, allowing for better answer validation and user feedback. - Enhanced the extraction of correct answers and alternatives to accommodate both single and multiple correct indices. - Improved CSS styles in VocabCourseView for better table layout, including adjustments for overflow handling and vertical alignment, enhancing overall user experience.
1638 lines
53 KiB
JavaScript
1638 lines
53 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 listAllLanguages() {
|
|
// Gibt alle verfügbaren Sprachen zurück (für Kursliste)
|
|
const rows = await sequelize.query(
|
|
`
|
|
SELECT
|
|
id,
|
|
name
|
|
FROM community.vocab_language
|
|
ORDER BY name ASC
|
|
`,
|
|
{
|
|
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);
|
|
|
|
// Konvertiere String-Parameter zu Booleans
|
|
const includePublicBool = includePublic === 'true' || includePublic === true;
|
|
const includeOwnBool = includeOwn === 'true' || includeOwn === true;
|
|
|
|
const where = {};
|
|
const andConditions = [];
|
|
|
|
// Zugriffsbedingungen
|
|
if (includeOwnBool && includePublicBool) {
|
|
andConditions.push({
|
|
[Op.or]: [
|
|
{ ownerUserId: user.id },
|
|
{ isPublic: true }
|
|
]
|
|
});
|
|
} else if (includeOwnBool) {
|
|
where.ownerUserId = user.id;
|
|
} else if (includePublicBool) {
|
|
where.isPublic = true;
|
|
}
|
|
|
|
// Filter nach Zielsprache (die zu lernende Sprache)
|
|
if (languageId) {
|
|
where.languageId = Number(languageId);
|
|
}
|
|
|
|
// Filter nach Muttersprache (die Sprache des Lerners)
|
|
// Wenn nativeLanguageId nicht gesetzt ist (undefined), zeige alle Kurse (kein Filter)
|
|
// Wenn nativeLanguageId === null oder 'null' (aus Frontend), zeige alle Kurse (kein Filter)
|
|
// Wenn nativeLanguageId eine Zahl ist, zeige nur Kurse für diese Muttersprache
|
|
if (nativeLanguageId !== undefined && nativeLanguageId !== null && nativeLanguageId !== 'null') {
|
|
where.nativeLanguageId = Number(nativeLanguageId);
|
|
}
|
|
// Wenn nativeLanguageId null/undefined/'null' ist, wird kein Filter angewendet = alle Kurse
|
|
|
|
// 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
|
|
// Wenn sowohl andConditions als auch direkte where-Eigenschaften existieren,
|
|
// müssen sie kombiniert werden
|
|
// WICHTIG: directWhereProps muss NACH dem Setzen aller direkten Eigenschaften berechnet werden
|
|
const directWhereProps = Object.keys(where).filter(key => {
|
|
// Filtere Op.and und Op.or heraus (sind Symbol-Keys)
|
|
return key !== Op.and && key !== Op.or && typeof key === 'string';
|
|
});
|
|
|
|
if (andConditions.length > 0) {
|
|
// Wenn where bereits direkte Eigenschaften hat, füge sie zu andConditions hinzu
|
|
if (directWhereProps.length > 0) {
|
|
const directWhere = {};
|
|
for (const key of directWhereProps) {
|
|
directWhere[key] = where[key];
|
|
delete where[key];
|
|
}
|
|
andConditions.push(directWhere);
|
|
}
|
|
// Entferne leere Objekte aus andConditions
|
|
const filteredConditions = andConditions.filter(cond => {
|
|
return cond && typeof cond === 'object' && Object.keys(cond).length > 0;
|
|
});
|
|
// Setze andConditions nur, wenn sie nicht leer sind
|
|
if (filteredConditions.length > 0) {
|
|
where[Op.and] = filteredConditions;
|
|
}
|
|
}
|
|
// Wenn nur direkte Eigenschaften existieren (andConditions.length === 0),
|
|
// bleiben sie in where (nichts zu tun, sie sind bereits dort)
|
|
|
|
const courses = await VocabCourse.findAll({
|
|
where,
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
|
|
// Debug-Logging (kann später entfernt werden)
|
|
console.log(`[getCourses] Gefunden: ${courses.length} Kurse`, {
|
|
userId: user.id,
|
|
languageId,
|
|
nativeLanguageId,
|
|
search,
|
|
whereBefore: JSON.stringify(where, null, 2),
|
|
includePublic: includePublicBool,
|
|
includeOwn: includeOwnBool,
|
|
andConditionsLength: andConditions.length,
|
|
directWherePropsBefore: Object.keys(where).filter(key => key !== Op.and && key !== Op.or),
|
|
whereAfter: JSON.stringify(where, null, 2)
|
|
});
|
|
|
|
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
|
|
}
|
|
);
|
|
if (Array.isArray(languages)) {
|
|
const languageMap = new Map(languages.map(l => [l.id, l.name]));
|
|
coursesData.forEach(c => {
|
|
c.languageName = languageMap.get(c.languageId) || null;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Lade Muttersprachen-Namen für alle Kurse
|
|
const nativeLanguageIds = [...new Set(coursesData.map(c => c.nativeLanguageId).filter(id => id !== null))];
|
|
if (nativeLanguageIds.length > 0) {
|
|
const nativeLanguages = await sequelize.query(
|
|
`SELECT id, name FROM community.vocab_language WHERE id IN (:nativeLanguageIds)`,
|
|
{
|
|
replacements: { nativeLanguageIds },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
if (Array.isArray(nativeLanguages)) {
|
|
const nativeLanguageMap = new Map(nativeLanguages.map(l => [l.id, l.name]));
|
|
coursesData.forEach(c => {
|
|
c.nativeLanguageName = c.nativeLanguageId ? nativeLanguageMap.get(c.nativeLanguageId) || null : 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 getLesson(hashedUserId, lessonId) {
|
|
const user = await this._getUserByHashedId(hashedUserId);
|
|
const lesson = await VocabCourseLesson.findByPk(lessonId, {
|
|
include: [
|
|
{
|
|
model: VocabCourse,
|
|
as: 'course'
|
|
},
|
|
{
|
|
model: VocabGrammarExercise,
|
|
as: 'grammarExercises',
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
}
|
|
],
|
|
required: false,
|
|
separate: true,
|
|
order: [['exerciseNumber', 'ASC']]
|
|
}
|
|
]
|
|
});
|
|
|
|
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 plainLesson = lesson.get({ plain: true });
|
|
|
|
// Bei Wiederholungslektionen: Lade Vokabeln aus vorherigen Lektionen
|
|
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review') {
|
|
plainLesson.reviewLessons = await this._getReviewLessons(plainLesson.courseId, plainLesson.lessonNumber);
|
|
plainLesson.reviewVocabExercises = await this._getReviewVocabExercises(plainLesson.courseId, plainLesson.lessonNumber);
|
|
}
|
|
|
|
console.log(`[getLesson] Lektion ${lessonId} geladen:`, {
|
|
id: plainLesson.id,
|
|
title: plainLesson.title,
|
|
lessonType: plainLesson.lessonType,
|
|
exerciseCount: plainLesson.grammarExercises ? plainLesson.grammarExercises.length : 0,
|
|
reviewLessonsCount: plainLesson.reviewLessons ? plainLesson.reviewLessons.length : 0,
|
|
reviewVocabExercisesCount: plainLesson.reviewVocabExercises ? plainLesson.reviewVocabExercises.length : 0
|
|
});
|
|
return plainLesson;
|
|
}
|
|
|
|
/**
|
|
* Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
|
|
*/
|
|
async _getReviewLessons(courseId, currentLessonNumber) {
|
|
const lessons = await VocabCourseLesson.findAll({
|
|
where: {
|
|
courseId: courseId,
|
|
lessonNumber: {
|
|
[Op.lt]: currentLessonNumber // Nur Lektionen mit kleinerer Nummer
|
|
},
|
|
lessonType: {
|
|
[Op.notIn]: ['review', 'vocab_review'] // Keine anderen Wiederholungslektionen
|
|
}
|
|
},
|
|
order: [['lessonNumber', 'ASC']],
|
|
attributes: ['id', 'lessonNumber', 'title']
|
|
});
|
|
return lessons.map(l => l.get({ plain: true }));
|
|
}
|
|
|
|
/**
|
|
* Sammelt alle Grammatik-Übungen aus vorherigen Lektionen für Wiederholungslektionen
|
|
*/
|
|
async _getReviewVocabExercises(courseId, currentLessonNumber) {
|
|
const previousLessons = await VocabCourseLesson.findAll({
|
|
where: {
|
|
courseId: courseId,
|
|
lessonNumber: {
|
|
[Op.lt]: currentLessonNumber
|
|
},
|
|
lessonType: {
|
|
[Op.notIn]: ['review', 'vocab_review']
|
|
}
|
|
},
|
|
attributes: ['id']
|
|
});
|
|
|
|
if (previousLessons.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const lessonIds = previousLessons.map(l => l.id);
|
|
const exercises = await VocabGrammarExercise.findAll({
|
|
where: {
|
|
lessonId: {
|
|
[Op.in]: lessonIds
|
|
}
|
|
},
|
|
include: [
|
|
{
|
|
model: VocabGrammarExerciseType,
|
|
as: 'exerciseType'
|
|
},
|
|
{
|
|
model: VocabCourseLesson,
|
|
as: 'lesson',
|
|
attributes: ['id', 'lessonNumber', 'title']
|
|
}
|
|
],
|
|
order: [
|
|
[{ model: VocabCourseLesson, as: 'lesson' }, 'lessonNumber', 'ASC'],
|
|
['exerciseNumber', 'ASC']
|
|
]
|
|
});
|
|
|
|
return exercises.map(e => e.get({ plain: 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, exercise.questionData, 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();
|
|
}
|
|
|
|
// Extrahiere richtige Antwort und Alternativen
|
|
const answerData = typeof exercise.answerData === 'string'
|
|
? JSON.parse(exercise.answerData)
|
|
: exercise.answerData;
|
|
const questionData = typeof exercise.questionData === 'string'
|
|
? JSON.parse(exercise.questionData)
|
|
: exercise.questionData;
|
|
|
|
let correctAnswer = null;
|
|
let alternatives = [];
|
|
|
|
// Für Multiple Choice: Extrahiere die richtige(n) Antwort(en) aus dem Index/den Indizes
|
|
if (exercise.exerciseTypeId === 2 && answerData.correctAnswer !== undefined) {
|
|
const options = questionData?.options || [];
|
|
|
|
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
|
|
let correctIndices = [];
|
|
if (Array.isArray(answerData.correctAnswer)) {
|
|
correctIndices = answerData.correctAnswer.map(idx => Number(idx));
|
|
} else {
|
|
correctIndices = [Number(answerData.correctAnswer)];
|
|
}
|
|
|
|
// Extrahiere alle korrekten Antworten
|
|
const correctAnswersList = correctIndices
|
|
.map(idx => options[idx])
|
|
.filter(opt => opt !== undefined);
|
|
|
|
if (correctAnswersList.length > 0) {
|
|
// Wenn mehrere richtige Antworten: Zeige alle an, getrennt durch " / "
|
|
correctAnswer = correctAnswersList.join(' / ');
|
|
}
|
|
|
|
// Alternativen sind alle anderen Optionen (nicht korrekte)
|
|
alternatives = options.filter((opt, idx) => !correctIndices.includes(idx));
|
|
}
|
|
// Für Gap Fill: Extrahiere aus answers Array
|
|
else if (exercise.exerciseTypeId === 1 && answerData.answers) {
|
|
correctAnswer = Array.isArray(answerData.answers)
|
|
? answerData.answers.join(', ')
|
|
: answerData.answers;
|
|
}
|
|
// Für Reading Aloud: Extrahiere den erwarteten Text
|
|
else if (questionData.type === 'reading_aloud') {
|
|
correctAnswer = questionData.text || answerData.expectedText || '';
|
|
}
|
|
// Für Speaking From Memory: Extrahiere erwarteten Text oder Schlüsselwörter
|
|
else if (questionData.type === 'speaking_from_memory') {
|
|
correctAnswer = questionData.expectedText || questionData.text || '';
|
|
alternatives = questionData.keywords || [];
|
|
}
|
|
// Fallback: Versuche correct oder correctAnswer
|
|
else {
|
|
correctAnswer = Array.isArray(answerData.correct)
|
|
? answerData.correct[0]
|
|
: (answerData.correct || answerData.correctAnswer);
|
|
alternatives = answerData.alternatives || [];
|
|
}
|
|
|
|
return {
|
|
correct: isCorrect,
|
|
correctAnswer: correctAnswer,
|
|
alternatives: alternatives,
|
|
explanation: exercise.explanation,
|
|
progress: progress.get({ plain: true })
|
|
};
|
|
}
|
|
|
|
async _getExerciseTypeIdByName(typeName) {
|
|
const type = await VocabGrammarExerciseType.findOne({ where: { name: typeName } });
|
|
return type ? type.id : null;
|
|
}
|
|
|
|
_checkAnswer(answerData, questionData, userAnswer, exerciseTypeId) {
|
|
// Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden
|
|
if (!answerData || userAnswer === undefined || userAnswer === null) return false;
|
|
|
|
// Parse JSON strings
|
|
const parsedAnswerData = typeof answerData === 'string' ? JSON.parse(answerData) : answerData;
|
|
const parsedQuestionData = typeof questionData === 'string' ? JSON.parse(questionData) : questionData;
|
|
|
|
// Für Multiple Choice: Prüfe ob userAnswer (Index) mit correctAnswer (Index oder Array von Indizes) übereinstimmt
|
|
if (exerciseTypeId === 2) { // multiple_choice
|
|
// Unterstütze sowohl einzelne korrekte Antwort als auch Array von korrekten Antworten
|
|
let correctIndices = [];
|
|
|
|
if (parsedAnswerData.correctAnswer !== undefined) {
|
|
// Kann ein einzelner Index oder ein Array von Indizes sein
|
|
if (Array.isArray(parsedAnswerData.correctAnswer)) {
|
|
correctIndices = parsedAnswerData.correctAnswer.map(idx => Number(idx));
|
|
} else {
|
|
correctIndices = [Number(parsedAnswerData.correctAnswer)];
|
|
}
|
|
} else if (parsedAnswerData.correct !== undefined) {
|
|
// Fallback: Prüfe auch 'correct' Feld
|
|
if (Array.isArray(parsedAnswerData.correct)) {
|
|
correctIndices = parsedAnswerData.correct.map(idx => Number(idx));
|
|
} else {
|
|
correctIndices = [Number(parsedAnswerData.correct)];
|
|
}
|
|
}
|
|
|
|
if (correctIndices.length === 0) return false;
|
|
|
|
// userAnswer ist der Index (0, 1, 2, ...)
|
|
const userIndex = Number(userAnswer);
|
|
return correctIndices.includes(userIndex);
|
|
}
|
|
|
|
// Für Lückentext: Normalisiere und vergleiche
|
|
if (exerciseTypeId === 1) { // gap_fill
|
|
const normalize = (str) => String(str || '').trim().toLowerCase();
|
|
const correctAnswers = parsedAnswerData.answers || parsedAnswerData.correct || [];
|
|
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
|
|
|
|
// userAnswer ist ein Array von Antworten
|
|
if (Array.isArray(userAnswer)) {
|
|
if (userAnswer.length !== correctAnswersArray.length) return false;
|
|
return userAnswer.every((ans, idx) => {
|
|
const correct = correctAnswersArray[idx];
|
|
return normalize(ans) === normalize(correct);
|
|
});
|
|
} else {
|
|
// Fallback: Einzelne Antwort
|
|
const normalizedUserAnswer = normalize(userAnswer);
|
|
return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer);
|
|
}
|
|
}
|
|
|
|
// Für Reading Aloud: userAnswer ist der erkannte Text (String)
|
|
// Vergleiche mit dem erwarteten Text aus questionData.text
|
|
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') {
|
|
const normalize = (str) => String(str || '').trim().toLowerCase().replace(/[.,!?;:]/g, '');
|
|
const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
|
|
const normalizedExpected = normalize(expectedText);
|
|
const normalizedUser = normalize(userAnswer);
|
|
|
|
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
|
|
if (parsedQuestionData.type === 'reading_aloud') {
|
|
// Exakter Vergleich (kann später mit Levenshtein erweitert werden)
|
|
return normalizedUser === normalizedExpected;
|
|
}
|
|
|
|
// Für speaking_from_memory: Flexibler Vergleich (Schlüsselwörter)
|
|
if (parsedQuestionData.type === 'speaking_from_memory') {
|
|
const keywords = parsedQuestionData.keywords || [];
|
|
if (keywords.length === 0) {
|
|
// Fallback: Exakter Vergleich
|
|
return normalizedUser === normalizedExpected;
|
|
}
|
|
// Prüfe ob alle Schlüsselwörter vorhanden sind
|
|
return keywords.every(keyword => normalizedUser.includes(normalize(keyword)));
|
|
}
|
|
}
|
|
|
|
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
|
|
const normalize = (str) => String(str || '').trim().toLowerCase();
|
|
const correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
|
|
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
|
|
const normalizedUserAnswer = normalize(userAnswer);
|
|
return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
|