- 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.
486 lines
14 KiB
JavaScript
486 lines
14 KiB
JavaScript
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) };
|
|
});
|
|
}
|
|
}
|
|
|
|
|