Add Vocab Trainer feature with routing, database schema, and translations
- Introduced Vocab Trainer functionality, including new routes for managing languages and chapters. - Implemented database schema for vocab-related tables to ensure data integrity. - Updated navigation and UI components to include Vocab Trainer in the social network menu. - Added translations for Vocab Trainer in both German and English locales, enhancing user accessibility.
This commit is contained in:
485
backend/services/vocabService.js
Normal file
485
backend/services/vocabService.js
Normal file
@@ -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) };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user