diff --git a/backend/app.js b/backend/app.js index f21832f..a02d63c 100644 --- a/backend/app.js +++ b/backend/app.js @@ -18,6 +18,7 @@ import taxiRouter from './routers/taxiRouter.js'; import taxiMapRouter from './routers/taxiMapRouter.js'; import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js'; import termineRouter from './routers/termineRouter.js'; +import vocabRouter from './routers/vocabRouter.js'; import cors from 'cors'; import './jobs/sessionCleanup.js'; @@ -69,6 +70,7 @@ app.use('/api/taxi/highscores', taxiHighscoreRouter); app.use('/images', express.static(path.join(__dirname, '../frontend/public/images'))); app.use('/api/contact', contactRouter); app.use('/api/socialnetwork', socialnetworkRouter); +app.use('/api/vocab', vocabRouter); app.use('/api/forum', forumRouter); app.use('/api/falukant', falukantRouter); app.use('/api/friendships', friendshipRouter); diff --git a/backend/controllers/navigationController.js b/backend/controllers/navigationController.js index 8f566e6..ad5fc4b 100644 --- a/backend/controllers/navigationController.js +++ b/backend/controllers/navigationController.js @@ -4,6 +4,7 @@ import UserRight from '../models/community/user_right.js'; import UserRightType from '../models/type/user_right.js'; import UserParamType from '../models/type/user_param.js'; import FalukantUser from '../models/falukant/data/user.js'; +import VocabService from '../services/vocabService.js'; const menuStructure = { home: { @@ -49,6 +50,11 @@ const menuStructure = { visible: ["all"], path: "/socialnetwork/gallery" }, + vocabtrainer: { + visible: ["all"], + path: "/socialnetwork/vocab", + children: {} + }, blockedUsers: { visible: ["all"], path: "/socialnetwork/blocked" @@ -296,6 +302,7 @@ const menuStructure = { class NavigationController { constructor() { this.menu = this.menu.bind(this); + this.vocabService = new VocabService(); } calculateAge(birthDate) { @@ -365,6 +372,24 @@ class NavigationController { const age = this.calculateAge(birthDate); const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean); const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id); + + // Dynamisches Submenü: Treffpunkt → Vokabeltrainer → (Neue Sprache + abonnierte/angelegte) + // Wichtig: "Neue Sprache" soll IMMER sichtbar sein – auch wenn die DB-Abfrage (noch) fehlschlägt. + if (filteredMenu?.socialnetwork?.children?.vocabtrainer) { + const children = { + newLanguage: { path: '/socialnetwork/vocab/new' }, + }; + try { + const langs = await this.vocabService.listLanguagesForMenu(user.id); + for (const l of langs) { + children[`lang_${l.id}`] = { path: `/socialnetwork/vocab/${l.id}`, label: l.name }; + } + } catch (e) { + console.warn('[menu] Konnte Vokabeltrainer-Sprachen nicht laden:', e?.message || e); + } + filteredMenu.socialnetwork.children.vocabtrainer.children = children; + } + res.status(200).json(filteredMenu); } catch (error) { console.error('Error fetching menu:', error); diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js new file mode 100644 index 0000000..71f9abd --- /dev/null +++ b/backend/controllers/vocabController.js @@ -0,0 +1,45 @@ +import VocabService from '../services/vocabService.js'; + +function extractHashedUserId(req) { + return req.headers?.userid; +} + +class VocabController { + constructor() { + this.service = new VocabService(); + + this.listLanguages = this._wrapWithUser((userId) => this.service.listLanguages(userId)); + this.createLanguage = this._wrapWithUser((userId, req) => this.service.createLanguage(userId, req.body), { successStatus: 201 }); + this.subscribe = this._wrapWithUser((userId, req) => this.service.subscribeByShareCode(userId, req.body), { successStatus: 201 }); + this.getLanguage = this._wrapWithUser((userId, req) => this.service.getLanguage(userId, req.params.languageId)); + + this.listChapters = this._wrapWithUser((userId, req) => this.service.listChapters(userId, req.params.languageId)); + this.createChapter = this._wrapWithUser((userId, req) => this.service.createChapter(userId, req.params.languageId, req.body), { successStatus: 201 }); + this.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId)); + + this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId)); + this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId)); + this.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 }); + } + + _wrapWithUser(fn, { successStatus = 200 } = {}) { + return async (req, res) => { + try { + const hashedUserId = extractHashedUserId(req); + if (!hashedUserId) { + return res.status(400).json({ error: 'Missing user identifier' }); + } + const result = await fn(hashedUserId, req, res); + res.status(successStatus).json(result); + } catch (error) { + console.error('Controller error:', error); + const status = error.status && typeof error.status === 'number' ? error.status : 500; + res.status(status).json({ error: error.message || 'Internal error' }); + } + }; + } +} + +export default VocabController; + + diff --git a/backend/migrations/20251230000000-add-vocab-trainer-tables.cjs b/backend/migrations/20251230000000-add-vocab-trainer-tables.cjs new file mode 100644 index 0000000..122a82a --- /dev/null +++ b/backend/migrations/20251230000000-add-vocab-trainer-tables.cjs @@ -0,0 +1,61 @@ +/* eslint-disable */ +'use strict'; + +module.exports = { + async up(queryInterface) { + // Sprache / Set, das geteilt werden kann + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_language ( + id SERIAL PRIMARY KEY, + owner_user_id INTEGER NOT NULL, + name TEXT NOT NULL, + share_code TEXT NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_language_owner_fk + FOREIGN KEY (owner_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code) + ); + `); + + // Abos (Freunde) + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_language_subscription ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + language_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_language_subscription_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_language_subscription_language_fk + FOREIGN KEY (language_id) + REFERENCES community.vocab_language(id) + ON DELETE CASCADE, + CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id) + ); + `); + + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_language_owner_idx + ON community.vocab_language(owner_user_id); + `); + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx + ON community.vocab_language_subscription(user_id); + `); + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx + ON community.vocab_language_subscription(language_id); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language_subscription;`); + await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language;`); + } +}; + + diff --git a/backend/migrations/20251230001000-add-vocab-chapters-and-lexemes.cjs b/backend/migrations/20251230001000-add-vocab-chapters-and-lexemes.cjs new file mode 100644 index 0000000..3a97935 --- /dev/null +++ b/backend/migrations/20251230001000-add-vocab-chapters-and-lexemes.cjs @@ -0,0 +1,106 @@ +/* eslint-disable */ +'use strict'; + +module.exports = { + async up(queryInterface) { + // Kapitel innerhalb einer Sprache + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_chapter ( + id SERIAL PRIMARY KEY, + language_id INTEGER NOT NULL, + title TEXT NOT NULL, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_chapter_language_fk + FOREIGN KEY (language_id) + REFERENCES community.vocab_language(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chapter_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE + ); + `); + + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx + ON community.vocab_chapter(language_id); + `); + + // Lexeme/Wörter (wir deduplizieren pro Sprache über normalized) + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_lexeme ( + id SERIAL PRIMARY KEY, + language_id INTEGER NOT NULL, + text TEXT NOT NULL, + normalized TEXT NOT NULL, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_lexeme_language_fk + FOREIGN KEY (language_id) + REFERENCES community.vocab_language(id) + ON DELETE CASCADE, + CONSTRAINT vocab_lexeme_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized) + ); + `); + + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx + ON community.vocab_lexeme(language_id); + `); + + // n:m Zuordnung pro Kapitel: Lernwort ↔ Referenzwort (Mehrdeutigkeiten möglich) + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme ( + id SERIAL PRIMARY KEY, + chapter_id INTEGER NOT NULL, + learning_lexeme_id INTEGER NOT NULL, + reference_lexeme_id INTEGER NOT NULL, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_chlex_chapter_fk + FOREIGN KEY (chapter_id) + REFERENCES community.vocab_chapter(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chlex_learning_fk + FOREIGN KEY (learning_lexeme_id) + REFERENCES community.vocab_lexeme(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chlex_reference_fk + FOREIGN KEY (reference_lexeme_id) + REFERENCES community.vocab_lexeme(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chlex_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id) + ); + `); + + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx + ON community.vocab_chapter_lexeme(chapter_id); + `); + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx + ON community.vocab_chapter_lexeme(learning_lexeme_id); + `); + await queryInterface.sequelize.query(` + CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx + ON community.vocab_chapter_lexeme(reference_lexeme_id); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter_lexeme;`); + await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_lexeme;`); + await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter;`); + } +}; + + diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js new file mode 100644 index 0000000..78ce1c4 --- /dev/null +++ b/backend/routers/vocabRouter.js @@ -0,0 +1,26 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import VocabController from '../controllers/vocabController.js'; + +const router = express.Router(); +const vocabController = new VocabController(); + +router.use(authenticate); + +router.get('/languages', vocabController.listLanguages); +router.post('/languages', vocabController.createLanguage); +router.post('/subscribe', vocabController.subscribe); +router.get('/languages/:languageId', vocabController.getLanguage); + +// Kapitel +router.get('/languages/:languageId/chapters', vocabController.listChapters); +router.post('/languages/:languageId/chapters', vocabController.createChapter); +router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs); + +router.get('/chapters/:chapterId', vocabController.getChapter); +router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs); +router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter); + +export default router; + + diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js new file mode 100644 index 0000000..534f2b2 --- /dev/null +++ b/backend/services/vocabService.js @@ -0,0 +1,485 @@ +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 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) }; + }); + } +} + + diff --git a/backend/utils/syncDatabase.js b/backend/utils/syncDatabase.js index 036fd0c..c67ece1 100644 --- a/backend/utils/syncDatabase.js +++ b/backend/utils/syncDatabase.js @@ -33,6 +33,123 @@ const syncDatabase = async () => { console.log("Initializing database schemas..."); await initializeDatabase(); + // Vokabeltrainer: Tabellen sicherstellen (auch ohne manuell ausgeführte Migrations) + // Hintergrund: In Produktion sind Schema-Updates deaktiviert, und Migrations werden nicht automatisch ausgeführt. + // Damit API/Menu nicht mit "relation does not exist" (42P01) scheitert, legen wir die Tabellen idempotent an. + console.log("Ensuring Vocab-Trainer tables exist..."); + try { + await sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_language ( + id SERIAL PRIMARY KEY, + owner_user_id INTEGER NOT NULL, + name TEXT NOT NULL, + share_code TEXT NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_language_owner_fk + FOREIGN KEY (owner_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code) + ); + + CREATE TABLE IF NOT EXISTS community.vocab_language_subscription ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + language_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_language_subscription_user_fk + FOREIGN KEY (user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_language_subscription_language_fk + FOREIGN KEY (language_id) + REFERENCES community.vocab_language(id) + ON DELETE CASCADE, + CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id) + ); + + CREATE INDEX IF NOT EXISTS vocab_language_owner_idx + ON community.vocab_language(owner_user_id); + CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx + ON community.vocab_language_subscription(user_id); + CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx + ON community.vocab_language_subscription(language_id); + + CREATE TABLE IF NOT EXISTS community.vocab_chapter ( + id SERIAL PRIMARY KEY, + language_id INTEGER NOT NULL, + title TEXT NOT NULL, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_chapter_language_fk + FOREIGN KEY (language_id) + REFERENCES community.vocab_language(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chapter_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx + ON community.vocab_chapter(language_id); + + CREATE TABLE IF NOT EXISTS community.vocab_lexeme ( + id SERIAL PRIMARY KEY, + language_id INTEGER NOT NULL, + text TEXT NOT NULL, + normalized TEXT NOT NULL, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_lexeme_language_fk + FOREIGN KEY (language_id) + REFERENCES community.vocab_language(id) + ON DELETE CASCADE, + CONSTRAINT vocab_lexeme_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized) + ); + CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx + ON community.vocab_lexeme(language_id); + + CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme ( + id SERIAL PRIMARY KEY, + chapter_id INTEGER NOT NULL, + learning_lexeme_id INTEGER NOT NULL, + reference_lexeme_id INTEGER NOT NULL, + created_by_user_id INTEGER NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_chlex_chapter_fk + FOREIGN KEY (chapter_id) + REFERENCES community.vocab_chapter(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chlex_learning_fk + FOREIGN KEY (learning_lexeme_id) + REFERENCES community.vocab_lexeme(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chlex_reference_fk + FOREIGN KEY (reference_lexeme_id) + REFERENCES community.vocab_lexeme(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chlex_creator_fk + FOREIGN KEY (created_by_user_id) + REFERENCES community."user"(id) + ON DELETE CASCADE, + CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id) + ); + CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx + ON community.vocab_chapter_lexeme(chapter_id); + CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx + ON community.vocab_chapter_lexeme(learning_lexeme_id); + CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx + ON community.vocab_chapter_lexeme(reference_lexeme_id); + `); + console.log("✅ Vocab-Trainer Tabellen sind vorhanden."); + } catch (e) { + console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e); + } + // Vorab: Stelle kritische Spalten sicher, damit Index-Erstellung nicht fehlschlägt console.log("Pre-ensure Taxi columns (traffic_light) ..."); try { diff --git a/frontend/public/sounds/fail.mp3 b/frontend/public/sounds/fail.mp3 new file mode 100644 index 0000000..6b776c2 Binary files /dev/null and b/frontend/public/sounds/fail.mp3 differ diff --git a/frontend/public/sounds/success.mp3 b/frontend/public/sounds/success.mp3 new file mode 100644 index 0000000..ff10122 Binary files /dev/null and b/frontend/public/sounds/success.mp3 differ diff --git a/frontend/src/components/AppNavigation.vue b/frontend/src/components/AppNavigation.vue index 3434cd3..72ecd0d 100644 --- a/frontend/src/components/AppNavigation.vue +++ b/frontend/src/components/AppNavigation.vue @@ -27,7 +27,7 @@ :style="`background-image:url('/images/icons/${subitem.icon}')`" class="submenu-icon" >  - {{ $t(`navigation.m-${key}.${subkey}`) }} + {{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}   - {{ $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }} + {{ subsubitem?.label || $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }} diff --git a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue new file mode 100644 index 0000000..21c9492 --- /dev/null +++ b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue @@ -0,0 +1,504 @@ + + + + + + + diff --git a/frontend/src/i18n/locales/de/general.json b/frontend/src/i18n/locales/de/general.json index 07d7632..2a33163 100644 --- a/frontend/src/i18n/locales/de/general.json +++ b/frontend/src/i18n/locales/de/general.json @@ -26,7 +26,10 @@ } }, "general": { - "datetimelong": "dd.MM.yyyy HH:mm:ss" + "datetimelong": "dd.MM.yyyy HH:mm:ss", + "loading": "Lädt...", + "back": "Zurück", + "cancel": "Abbrechen" }, "OK": "Ok", "Cancel": "Abbrechen", diff --git a/frontend/src/i18n/locales/de/navigation.json b/frontend/src/i18n/locales/de/navigation.json index d0c900a..d3662a6 100644 --- a/frontend/src/i18n/locales/de/navigation.json +++ b/frontend/src/i18n/locales/de/navigation.json @@ -20,6 +20,7 @@ "usersearch": "Benutzersuche", "forum": "Forum", "gallery": "Galerie", + "vocabtrainer": "Vokabeltrainer", "blockedUsers": "Blockierte Benutzer", "oneTimeInvitation": "Einmal-Einladungen", "diary": "Tagebuch", @@ -27,6 +28,9 @@ "m-erotic": { "pictures": "Bilder", "videos": "Videos" + }, + "m-vocabtrainer": { + "newLanguage": "Neue Sprache" } }, "m-minigames": { diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index 3edea90..1e54681 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -249,5 +249,66 @@ "denied": "Du hast die Freundschaftsanfrage abgelehnt.", "accepted": "Die Freundschaft wurde geschlossen." } + , + "vocab": { + "title": "Vokabeltrainer", + "description": "Lege Sprachen an (oder abonniere sie) und teile sie mit Freunden.", + "newLanguage": "Neue Sprache", + "newLanguageTitle": "Neue Sprache anlegen", + "languageName": "Name der Sprache", + "create": "Anlegen", + "saving": "Speichere...", + "created": "Sprache wurde angelegt.", + "createdTitle": "Vokabeltrainer", + "createdMessage": "Sprache wurde angelegt. Das Menü wird aktualisiert.", + "createError": "Konnte die Sprache nicht anlegen.", + "openLanguage": "Öffnen", + "none": "Du hast noch keine Sprachen angelegt oder abonniert.", + "owner": "Eigen", + "subscribed": "Abonniert", + "languageTitle": "Vokabeltrainer: {name}", + "notFound": "Sprache nicht gefunden oder kein Zugriff.", + "shareCode": "Teilen-Code", + "shareHint": "Diesen Code kannst du an Freunde weitergeben, damit sie die Sprache abonnieren können.", + "subscribeByCode": "Per Code abonnieren", + "subscribeTitle": "Sprache abonnieren", + "subscribeHint": "Gib den Teilen-Code ein, den du von einem Freund bekommen hast.", + "subscribe": "Abonnieren", + "subscribeSuccess": "Abo erfolgreich. Menü wird aktualisiert.", + "subscribeError": "Abo fehlgeschlagen. Code ungültig oder kein Zugriff.", + "trainerPlaceholder": "Trainer-Funktionen (Vokabeln/Abfragen) kommen als nächster Schritt." + , + "chapters": "Kapitel", + "newChapter": "Neues Kapitel", + "createChapter": "Kapitel anlegen", + "createChapterError": "Konnte Kapitel nicht anlegen.", + "noChapters": "Noch keine Kapitel vorhanden.", + "chapterTitle": "Kapitel: {title}", + "addVocab": "Vokabel hinzufügen", + "learningWord": "Lernsprache", + "referenceWord": "Referenz", + "add": "Hinzufügen", + "addVocabError": "Konnte Vokabel nicht hinzufügen.", + "noVocabs": "In diesem Kapitel sind noch keine Vokabeln." + , + "practice": { + "open": "Üben", + "title": "Vokabeln üben", + "allVocabs": "Alle Vokabeln", + "simple": "Einfaches Üben", + "noPool": "Keine Vokabeln zum Üben vorhanden.", + "dirLearningToRef": "Lernsprache → Referenz", + "dirRefToLearning": "Referenz → Lernsprache", + "check": "Prüfen", + "next": "Weiter", + "skip": "Überspringen", + "correct": "Richtig!", + "wrong": "Falsch.", + "acceptable": "Mögliche richtige Übersetzungen:", + "stats": "Statistik", + "success": "Erfolg", + "fail": "Misserfolg" + } + } } } \ No newline at end of file diff --git a/frontend/src/i18n/locales/en/general.json b/frontend/src/i18n/locales/en/general.json index 781699e..1028dbf 100644 --- a/frontend/src/i18n/locales/en/general.json +++ b/frontend/src/i18n/locales/en/general.json @@ -6,6 +6,12 @@ "dataPrivacy": { "title": "Data Privacy Policy" }, + "general": { + "loading": "Loading...", + "back": "Back", + "cancel": "Cancel", + "datetimelong": "dd.MM.yyyy HH:mm:ss" + }, "message": { "close": "Close" }, diff --git a/frontend/src/i18n/locales/en/navigation.json b/frontend/src/i18n/locales/en/navigation.json index c6f571f..c0604ba 100644 --- a/frontend/src/i18n/locales/en/navigation.json +++ b/frontend/src/i18n/locales/en/navigation.json @@ -20,6 +20,7 @@ "usersearch": "User search", "forum": "Forum", "gallery": "Gallery", + "vocabtrainer": "Vocabulary trainer", "blockedUsers": "Blocked users", "oneTimeInvitation": "One-time invitations", "diary": "Diary", @@ -27,6 +28,9 @@ "m-erotic": { "pictures": "Pictures", "videos": "Videos" + }, + "m-vocabtrainer": { + "newLanguage": "New language" } }, "m-minigames": { diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index fd4179f..2e09e05 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -249,5 +249,66 @@ "denied": "You have denied the friendship request.", "accepted": "The friendship has been established." } + , + "vocab": { + "title": "Vocabulary trainer", + "description": "Create languages (or subscribe to them) and share them with friends.", + "newLanguage": "New language", + "newLanguageTitle": "Create new language", + "languageName": "Language name", + "create": "Create", + "saving": "Saving...", + "created": "Language created.", + "createdTitle": "Vocabulary trainer", + "createdMessage": "Language created. The menu will refresh.", + "createError": "Could not create language.", + "openLanguage": "Open", + "none": "You have no languages yet (created or subscribed).", + "owner": "Owned", + "subscribed": "Subscribed", + "languageTitle": "Vocabulary trainer: {name}", + "notFound": "Language not found or no access.", + "shareCode": "Share code", + "shareHint": "Send this code to friends so they can subscribe to this language.", + "subscribeByCode": "Subscribe by code", + "subscribeTitle": "Subscribe to language", + "subscribeHint": "Enter a share code you received from a friend.", + "subscribe": "Subscribe", + "subscribeSuccess": "Subscribed. The menu will refresh.", + "subscribeError": "Subscribe failed. Invalid code or no access.", + "trainerPlaceholder": "Trainer features (words/quizzes) will be the next step." + , + "chapters": "Chapters", + "newChapter": "New chapter", + "createChapter": "Create chapter", + "createChapterError": "Could not create chapter.", + "noChapters": "No chapters yet.", + "chapterTitle": "Chapter: {title}", + "addVocab": "Add vocabulary", + "learningWord": "To learn", + "referenceWord": "Reference", + "add": "Add", + "addVocabError": "Could not add vocabulary.", + "noVocabs": "No vocabulary in this chapter yet." + , + "practice": { + "open": "Practice", + "title": "Practice vocabulary", + "allVocabs": "All vocabulary", + "simple": "Simple practice", + "noPool": "No vocabulary to practice.", + "dirLearningToRef": "To learn → Reference", + "dirRefToLearning": "Reference → To learn", + "check": "Check", + "next": "Next", + "skip": "Skip", + "correct": "Correct!", + "wrong": "Wrong.", + "acceptable": "Acceptable answers:", + "stats": "Stats", + "success": "Success", + "fail": "Fail" + } + } } } \ No newline at end of file diff --git a/frontend/src/router/socialRoutes.js b/frontend/src/router/socialRoutes.js index eb51f0c..82d17ab 100644 --- a/frontend/src/router/socialRoutes.js +++ b/frontend/src/router/socialRoutes.js @@ -5,6 +5,11 @@ import GuestbookView from '../views/social/GuestbookView.vue'; import DiaryView from '../views/social/DiaryView.vue'; import ForumView from '../views/social/ForumView.vue'; import ForumTopicView from '../views/social/ForumTopicView.vue'; +import VocabTrainerView from '../views/social/VocabTrainerView.vue'; +import VocabNewLanguageView from '../views/social/VocabNewLanguageView.vue'; +import VocabLanguageView from '../views/social/VocabLanguageView.vue'; +import VocabSubscribeView from '../views/social/VocabSubscribeView.vue'; +import VocabChapterView from '../views/social/VocabChapterView.vue'; const socialRoutes = [ { @@ -49,6 +54,36 @@ const socialRoutes = [ component: DiaryView, meta: { requiresAuth: true } }, + { + path: '/socialnetwork/vocab', + name: 'VocabTrainer', + component: VocabTrainerView, + meta: { requiresAuth: true } + }, + { + path: '/socialnetwork/vocab/new', + name: 'VocabNewLanguage', + component: VocabNewLanguageView, + meta: { requiresAuth: true } + }, + { + path: '/socialnetwork/vocab/subscribe', + name: 'VocabSubscribe', + component: VocabSubscribeView, + meta: { requiresAuth: true } + }, + { + path: '/socialnetwork/vocab/:languageId', + name: 'VocabLanguage', + component: VocabLanguageView, + meta: { requiresAuth: true } + }, + { + path: '/socialnetwork/vocab/:languageId/chapters/:chapterId', + name: 'VocabChapter', + component: VocabChapterView, + meta: { requiresAuth: true } + }, ]; export default socialRoutes; diff --git a/frontend/src/views/social/VocabChapterView.vue b/frontend/src/views/social/VocabChapterView.vue new file mode 100644 index 0000000..a40c748 --- /dev/null +++ b/frontend/src/views/social/VocabChapterView.vue @@ -0,0 +1,153 @@ + + + + + + + diff --git a/frontend/src/views/social/VocabLanguageView.vue b/frontend/src/views/social/VocabLanguageView.vue new file mode 100644 index 0000000..5bfd595 --- /dev/null +++ b/frontend/src/views/social/VocabLanguageView.vue @@ -0,0 +1,149 @@ + + + + + + + diff --git a/frontend/src/views/social/VocabNewLanguageView.vue b/frontend/src/views/social/VocabNewLanguageView.vue new file mode 100644 index 0000000..a92df59 --- /dev/null +++ b/frontend/src/views/social/VocabNewLanguageView.vue @@ -0,0 +1,106 @@ + + + + + + + diff --git a/frontend/src/views/social/VocabSubscribeView.vue b/frontend/src/views/social/VocabSubscribeView.vue new file mode 100644 index 0000000..7b3e5cf --- /dev/null +++ b/frontend/src/views/social/VocabSubscribeView.vue @@ -0,0 +1,93 @@ + + + + + + + diff --git a/frontend/src/views/social/VocabTrainerView.vue b/frontend/src/views/social/VocabTrainerView.vue new file mode 100644 index 0000000..3a5ea1f --- /dev/null +++ b/frontend/src/views/social/VocabTrainerView.vue @@ -0,0 +1,86 @@ + + + + + + +