- Modified the searchVocabs method in VocabService to consolidate search parameters into a single query term for improved flexibility. - Updated VocabSearchDialog to replace separate input fields for mother tongue and learning language with a unified search term input. - Adjusted button logic to enable search only when a term is provided, enhancing user experience. - Added new translations for the search term in both German and English locales to support the updated UI.
533 lines
15 KiB
JavaScript
533 lines
15 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 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) };
|
|
});
|
|
}
|
|
}
|
|
|
|
|