import crypto from 'crypto'; import User from '../models/community/user.js'; import { sequelize } from '../utils/sequelize.js'; import { notifyUser } from '../utils/socket.js'; 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) }; }); } }