const { Vocab } = require('../models'); const { Op } = require('sequelize'); /** * ============================================ * CREATE VOCAB * ============================================ * Tạo mới một vocab entry * * @route POST /api/vocab * @access Private (requires authentication) * * INPUT: * { * text: String (required) - từ thực tế (wash, washes, washing, ate, eaten...) * ipa: String - phiên âm IPA (ví dụ: /wɒʃ/) * base_word: String (required) - từ gốc để nhóm lại (wash, eat...) * form_key: JSON - loại biến thể (V1, V2, V3, V_ing, Noun_Form...), mặc định 'base' * vi: String - nghĩa tiếng Việt * category: String - category của từ (Action Verbs, Nouns, etc.) * topic: String - chủ đề (Food, Travel, Education, etc.) * image_small: JSON Array - mảng URLs của hình ảnh nhỏ * image_square: JSON Array - mảng URLs của hình ảnh vuông * image_normal: JSON Array - mảng URLs của hình ảnh bình thường * audio: JSON Array - mảng URLs của audio files * example_sentences: JSON - các câu ví dụ * tags: JSON Array - các tags để phân loại * syntax: JSON - vai trò cú pháp (is_subject, is_verb, is_object, is_be, is_adj, is_adv, is_article, verb_type, etc.) * semantics: JSON - ràng buộc ngữ nghĩa (can_be_subject_type, can_take_object_type, can_modify, word_type, is_countable, person_type, etc.) * constraints: JSON - ràng buộc ngữ pháp (followed_by, match_subject, match_with, phonetic_rules, etc.) * } * * OUTPUT: * { * success: Boolean, * message: String, * data: { * vocab_id: UUID, * text: String, * ipa: String, * base_word: String, * form_key: JSON, * vi: String, * category: String, * topic: String, * image_small: JSON Array, * image_square: JSON Array, * image_normal: JSON Array, * audio: JSON Array, * example_sentences: JSON, * tags: JSON Array, * syntax: JSON, * semantics: JSON, * constraints: JSON, * is_active: Boolean, * created_at: Date, * updated_at: Date * } * } */ exports.createVocab = async (req, res) => { try { const { text, ipa, base_word, vi, category, topic, grade_number = 0, image_small, image_square, image_normal, audio, example_sentences, tags, syntax, semantics, constraints } = req.body; // Validate required fields if (!text || !base_word) { return res.status(400).json({ success: false, message: 'text and base_word are required fields' }); } // Create vocab entry const vocab = await Vocab.create({ text, ipa: ipa || null, base_word, vi: vi || '', category: category || null, topic: topic || null, image_small: image_small || null, image_square: image_square || null, image_normal: image_normal || null, audio: audio || null, example_sentences: example_sentences || null, tags: tags || null, syntax: syntax || null, semantics: semantics || null, constraints: constraints || null, grade_number: grade_number || 0, is_active: true }); res.status(201).json({ success: true, message: 'Vocabulary created successfully', data: vocab }); } catch (error) { console.error('Error creating vocab:', error); res.status(500).json({ success: false, message: 'Error creating vocabulary', error: error.message }); } }; /** * ============================================ * GET ALL VOCABS * ============================================ * Lấy danh sách vocab với phân trang và filter * * @route GET /api/vocab * @access Private * * INPUT (Query Parameters): * { * page: Number - trang hiện tại (mặc định: 1) * limit: Number - số items mỗi trang (mặc định: 20) * category: String - lọc theo category * topic: String - lọc theo topic * base_word: String - lọc theo base_word chính xác * text: String - lọc theo text chính xác * search: String - tìm kiếm trong text, base_word và vi * is_active: Boolean - lọc theo trạng thái active (mặc định: true) * } * * OUTPUT: * { * success: Boolean, * message: String, * data: Array of Vocab objects, * pagination: { * total: Number - tổng số records, * page: Number - trang hiện tại, * limit: Number - số items mỗi trang, * totalPages: Number - tổng số trang * } * } */ exports.getAllVocabs = async (req, res) => { try { const { page = 1, limit = 20, category, topic, base_word, text, search, grade_start, grade_end, status, sort, is_active = true } = req.query; const offset = (page - 1) * limit; const where = {}; // Apply filters if (is_active !== undefined) { where.is_active = is_active === 'true' || is_active === true; } // Status filter (0 = chưa duyệt, 1 = đã duyệt, undefined = all) if (status !== undefined && status !== '' && status !== 'all') { where.status = parseInt(status); } if (category) { where.category = category; } if (topic) { where.topic = topic; } if (base_word) { where.base_word = base_word; } if (text) { where.text = text; } // Search across multiple fields if (search) { where[Op.or] = [ { text: { [Op.like]: `%${search}%` } }, { base_word: { [Op.like]: `%${search}%` } }, { vi: { [Op.like]: `%${search}%` } } ]; } // Grade number range filter if (grade_start !== undefined && grade_end !== undefined) { const s = parseInt(grade_start); const e = parseInt(grade_end); if (s === e) { where.grade_number = s; } else if (s < e) { where.grade_number = { [Op.between]: [s, e] }; } } else if (grade_start !== undefined) { where.grade_number = { [Op.gte]: parseInt(grade_start) }; } else if (grade_end !== undefined) { where.grade_number = { [Op.lte]: parseInt(grade_end) }; } // Sort order let order = [['created_at', 'DESC']]; // default if (sort === 'updated_at') { order = [['updated_at', 'DESC']]; } else if (sort === 'alphabet') { order = [['text', 'ASC']]; } const { count, rows } = await Vocab.findAndCountAll({ where, limit: parseInt(limit), offset: parseInt(offset), order }); res.json({ success: true, message: 'Vocabularies retrieved successfully', data: rows, pagination: { total: count, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(count / limit) } }); } catch (error) { console.error('Error getting vocabs:', error); res.status(500).json({ success: false, message: 'Error retrieving vocabularies', error: error.message }); } }; /** * ============================================ * GET VOCAB BY ID * ============================================ * Lấy chi tiết một vocab theo vocab_id * * @route GET /api/vocab/:id * @access Private * * INPUT (URL Parameter): * { * id: UUID - vocab_id của vocab cần lấy * } * * OUTPUT: * { * success: Boolean, * message: String, * data: Vocab object với đầy đủ thông tin * } */ exports.getVocabById = async (req, res) => { try { const { id } = req.params; const vocab = await Vocab.findOne({ where: { vocab_id: id, is_active: true } }); if (!vocab) { return res.status(404).json({ success: false, message: 'Vocabulary not found' }); } res.json({ success: true, message: 'Vocabulary retrieved successfully', data: vocab }); } catch (error) { console.error('Error getting vocab:', error); res.status(500).json({ success: false, message: 'Error retrieving vocabulary', error: error.message }); } }; /** * ============================================ * GET VOCABS WITHOUT IPA * ============================================ * Lấy tất cả các vocab chưa có IPA * * @route GET /api/vocabs/missing/ipa * @access Private * * INPUT (Query Parameters): * { * page: Number - trang hiện tại (mặc định: 1) * limit: Number - số items mỗi trang (mặc định: 50) * } * * OUTPUT: * { * success: Boolean, * message: String, * data: Array of Vocab objects - các vocab chưa có IPA, * pagination: { * total: Number, * page: Number, * limit: Number, * totalPages: Number * } * } */ exports.getVocabsWithoutIpa = async (req, res) => { try { const { page = 1, limit = 50 } = req.query; const offset = (page - 1) * limit; const { count, rows } = await Vocab.findAndCountAll({ where: { is_active: true, [Op.or]: [ { ipa: null }, { ipa: '' } ] }, limit: parseInt(limit), offset: parseInt(offset), order: [['text', 'ASC']] }); res.json({ success: true, message: 'Vocabularies without IPA retrieved successfully', data: rows, pagination: { total: count, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(count / limit) } }); } catch (error) { console.error('Error getting vocabs without IPA:', error); res.status(500).json({ success: false, message: 'Error retrieving vocabularies without IPA', error: error.message }); } }; /** * ============================================ * GET VOCABS WITHOUT IMAGES * ============================================ * Lấy tất cả các vocab chưa đủ hình ảnh (thiếu image_small, image_square hoặc image_normal) * * @route GET /api/vocabs/missing/images * @access Private * * INPUT (Query Parameters): * { * page: Number - trang hiện tại (mặc định: 1) * limit: Number - số items mỗi trang (mặc định: 50) * } * * OUTPUT: * { * success: Boolean, * message: String, * data: Array of Vocab objects - các vocab chưa đủ hình ảnh, * pagination: { * total: Number, * page: Number, * limit: Number, * totalPages: Number * } * } */ exports.getVocabsWithoutImages = async (req, res) => { try { const { page = 1, limit = 50 } = req.query; const offset = (page - 1) * limit; const { count, rows } = await Vocab.findAndCountAll({ where: { is_active: true, [Op.or]: [ { image_small: null }, { image_square: null }, { image_normal: null } ] }, limit: parseInt(limit), offset: parseInt(offset), order: [['text', 'ASC']] }); res.json({ success: true, message: 'Vocabularies without images retrieved successfully', data: rows, pagination: { total: count, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(count / limit) } }); } catch (error) { console.error('Error getting vocabs without images:', error); res.status(500).json({ success: false, message: 'Error retrieving vocabularies without images', error: error.message }); } }; /** * ============================================ * UPDATE VOCAB * ============================================ * Cập nhật thông tin vocab * * @route PUT /api/vocab/:id * @access Private * * INPUT (URL Parameter + Body): * { * id: UUID - vocab_id cần update * Body: Object - các trường cần update (có thể update một hoặc nhiều trường) * { * text: String, * ipa: String, * base_word: String, * grade: Number, * form_key: JSON, * vi: String, * category: String, * etc: String, * topic: String, * image_small: JSON Array, * image_square: JSON Array, * image_normal: JSON Array, * audio: JSON Array, * example_sentences: JSON, * tags: JSON Array, * syntax: JSON, * semantics: JSON, * constraints: JSON, * is_active: Boolean * } * } * * OUTPUT: * { * success: Boolean, * message: String, * data: Updated Vocab object * } */ exports.updateVocab = async (req, res) => { try { const { id } = req.params; const updateData = req.body; const vocab = await Vocab.findOne({ where: { vocab_id: id } }); if (!vocab) { return res.status(404).json({ success: false, message: 'Vocabulary not found' }); } // Update vocab await vocab.update(updateData); res.json({ success: true, message: 'Vocabulary updated successfully', data: vocab }); } catch (error) { console.error('Error updating vocab:', error); res.status(500).json({ success: false, message: 'Error updating vocabulary', error: error.message }); } }; /** * ============================================ * DELETE VOCAB (SOFT DELETE) * ============================================ * Xóa mềm vocab (set is_active = false) * * @route DELETE /api/vocab/:id * @access Private * * INPUT (URL Parameter): * { * id: UUID - vocab_id cần xóa * } * * OUTPUT: * { * success: Boolean, * message: String - "Vocabulary deleted successfully" * } */ exports.deleteVocab = async (req, res) => { try { const { id } = req.params; const vocab = await Vocab.findOne({ where: { vocab_id: id } }); if (!vocab) { return res.status(404).json({ success: false, message: 'Vocabulary not found' }); } // Soft delete await vocab.update({ is_active: false }); res.json({ success: true, message: 'Vocabulary deleted successfully' }); } catch (error) { console.error('Error deleting vocab:', error); res.status(500).json({ success: false, message: 'Error deleting vocabulary', error: error.message }); } }; /** * ============================================ * BULK CREATE VOCABS * ============================================ * Tạo nhiều vocab cùng lúc * * @route POST /api/vocab/bulk * @access Private * * INPUT (Body): * { * vocabs: Array of Vocab objects - mỗi object phải có đầy đủ trường required * [ * { * text: String (required), * base_word: String (required), * ipa: String, * grade: Number, * form_key: JSON, * vi: String, * ... * }, * ... * ] * } * * OUTPUT: * { * success: Boolean, * message: String - "X vocabularies created successfully", * data: Array of created Vocab objects, * count: Number - số lượng vocab đã tạo * } */ exports.bulkCreateVocabs = async (req, res) => { try { const { vocabs } = req.body; if (!Array.isArray(vocabs) || vocabs.length === 0) { return res.status(400).json({ success: false, message: 'vocabs must be a non-empty array' }); } // Validate each vocab has required fields for (let i = 0; i < vocabs.length; i++) { if (!vocabs[i].text || !vocabs[i].base_word) { return res.status(400).json({ success: false, message: `Vocab at index ${i} is missing required fields (text, base_word)` }); } } const createdVocabs = await Vocab.bulkCreate(vocabs); res.status(201).json({ success: true, message: `${createdVocabs.length} vocabularies created successfully`, data: createdVocabs, count: createdVocabs.length }); } catch (error) { console.error('Error bulk creating vocabs:', error); res.status(500).json({ success: false, message: 'Error creating vocabularies', error: error.message }); } }; /** * ============================================ * GET VOCAB STATISTICS * ============================================ * Lấy thống kê về vocab * * @route GET /api/vocab/stats/overview * @access Private * * INPUT: Không có * * OUTPUT: * { * success: Boolean, * message: String, * data: { * total: { * active: Number - số vocab đang active, * inactive: Number - số vocab bị xóa, * all: Number - tổng số vocab * }, * unique_base_words: Number - số lượng từ gốc duy nhất, * by_category: Array - thống kê theo category * [ * { * category: String, * count: Number * }, * ... * ], * by_topic: Array - thống kê theo topic * [ * { * topic: String, * count: Number * }, * ... * ] * } * } */ exports.getVocabStats = async (req, res) => { try { const { sequelize } = Vocab; // Total active vocabs const totalActive = await Vocab.count({ where: { is_active: true } }); const totalInactive = await Vocab.count({ where: { is_active: false } }); // By category const byCategory = await Vocab.findAll({ where: { is_active: true }, attributes: [ 'category', [sequelize.fn('COUNT', sequelize.col('vocab_id')), 'count'] ], group: ['category'], order: [[sequelize.fn('COUNT', sequelize.col('vocab_id')), 'DESC']], raw: true }); // By topic const byTopic = await Vocab.findAll({ where: { is_active: true }, attributes: [ 'topic', [sequelize.fn('COUNT', sequelize.col('vocab_id')), 'count'] ], group: ['topic'], order: [[sequelize.fn('COUNT', sequelize.col('vocab_id')), 'DESC']], raw: true }); // Count unique base words const uniqueBaseWords = await Vocab.count({ where: { is_active: true }, distinct: true, col: 'base_word' }); res.json({ success: true, message: 'Statistics retrieved successfully', data: { total: { active: totalActive, inactive: totalInactive, all: totalActive + totalInactive }, unique_base_words: uniqueBaseWords, by_category: byCategory, by_topic: byTopic } }); } catch (error) { console.error('Error getting vocab stats:', error); res.status(500).json({ success: false, message: 'Error retrieving statistics', error: error.message }); } }; /** * ============================================ * SEARCH VOCABS * ============================================ * Tìm kiếm vocab nâng cao với nhiều filter * * @route POST /api/vocabs/search * @access Private * * INPUT (Body): * { * topic: String (optional) - chủ đề * category: String (optional) - loại từ * v_type: Boolean (optional) - tìm các biến thể khác của cùng một base_word * base_word_filter: String (optional) - base_word cụ thể (dùng khi v_type=true) * * shuffle_pos: Object (optional) - tìm từ thay thế dựa trên syntax * { * is_subject: Boolean, * is_verb: Boolean, * is_object: Boolean, * is_be: Boolean, * is_adj: Boolean, * is_adv: Boolean, * is_article: Boolean * } * * page: Number - trang hiện tại (mặc định: 1) * limit: Number - số items mỗi trang (mặc định: 100) * } * * OUTPUT: * { * success: Boolean, * message: String, * data: Array of Vocab objects, * pagination: { * total: Number, * page: Number, * limit: Number, * totalPages: Number * } * } */ exports.searchVocabs = async (req, res) => { try { const { topic, category, base_word, form_key, text, vi, grade_start, grade_end, v_type, shuffle_pos, page = 1, limit = 100 } = req.body; const offset = (page - 1) * limit; const where = { is_active: true }; // Filter by topic if (topic) { where.topic = topic; } // Filter by category if (category) { where.category = category; } // Filter by base_word if (base_word) { where.base_word = { [Op.like]: `%${base_word}%` }; } // Filter by form_key if (form_key) { where.form_key = form_key; } // Filter by text (partial match) if (text) { where.text = { [Op.like]: `%${text}%` }; } // Filter by vi (Vietnamese meaning - partial match) if (vi) { where.vi = { [Op.like]: `%${vi}%` }; } // Grade number range filter if (grade_start !== undefined && grade_end !== undefined) { const s = parseInt(grade_start); const e = parseInt(grade_end); if (s === e) { where.grade_number = s; } else if (s < e) { where.grade_number = { [Op.between]: [s, e] }; } } else if (grade_start !== undefined) { where.grade_number = { [Op.gte]: parseInt(grade_start) }; } else if (grade_end !== undefined) { where.grade_number = { [Op.lte]: parseInt(grade_end) }; } // Handle v_type: find variants of same base_word if (v_type === true) { // Nếu có v_type, cần tìm tất cả các từ có cùng base_word // Thông thường sẽ kết hợp với một vocab_id hoặc base_word cụ thể // Để linh hoạt, ta có thể group by base_word và lấy tất cả variants // Hoặc nếu user cung cấp thêm base_word trong query const { base_word_filter } = req.body; if (base_word_filter) { where.base_word = base_word_filter; } } // Handle shuffle_pos: find replacement words based on syntax if (shuffle_pos && typeof shuffle_pos === 'object') { const syntaxConditions = []; if (shuffle_pos.is_subject === true) { syntaxConditions.push({ 'syntax.is_subject': true }); } if (shuffle_pos.is_verb === true) { syntaxConditions.push({ 'syntax.is_verb': true }); } if (shuffle_pos.is_object === true) { syntaxConditions.push({ 'syntax.is_object': true }); } if (shuffle_pos.is_be === true) { syntaxConditions.push({ 'syntax.is_be': true }); } if (shuffle_pos.is_adj === true) { syntaxConditions.push({ 'syntax.is_adj': true }); } if (shuffle_pos.is_adv === true) { syntaxConditions.push({ 'syntax.is_adv': true }); } if (shuffle_pos.is_article === true) { syntaxConditions.push({ 'syntax.is_article': true }); } // Nếu có điều kiện syntax, ta cần query JSON field // Sequelize hỗ trợ JSON query cho MySQL/PostgreSQL if (syntaxConditions.length > 0) { const { Sequelize } = require('sequelize'); const orConditions = syntaxConditions.map(condition => { const key = Object.keys(condition)[0]; const jsonPath = key.replace('syntax.', ''); return Sequelize.where( Sequelize.fn('JSON_EXTRACT', Sequelize.col('syntax'), `$.${jsonPath}`), true ); }); if (orConditions.length === 1) { where[Op.and] = where[Op.and] || []; where[Op.and].push(orConditions[0]); } else { where[Op.and] = where[Op.and] || []; where[Op.and].push({ [Op.or]: orConditions }); } } } const { count, rows } = await Vocab.findAndCountAll({ where, limit: parseInt(limit), offset: parseInt(offset), order: [['text', 'ASC']] }); res.json({ success: true, message: 'Search completed successfully', data: rows, pagination: { total: count, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(count / limit) } }); } catch (error) { console.error('Error searching vocabs:', error); res.status(500).json({ success: false, message: 'Error searching vocabularies', error: error.message }); } }; /** * ============================================ * GET ALL CATEGORIES * ============================================ * Lấy danh sách tất cả categories có trong database * * @route GET /api/vocab/meta/categories * @access Private * * INPUT: Không có * * OUTPUT: * { * success: Boolean, * message: String, * data: Array of String - danh sách categories, * count: Number - số lượng categories * } */ exports.getAllCategories = async (req, res) => { try { const categories = await Vocab.findAll({ where: { is_active: true, category: { [Op.ne]: null } }, attributes: ['category'], group: ['category'], order: [['category', 'ASC']], raw: true }); const categoryList = categories.map(c => c.category); res.json({ success: true, message: 'Categories retrieved successfully', data: categoryList, count: categoryList.length }); } catch (error) { console.error('Error getting categories:', error); res.status(500).json({ success: false, message: 'Error retrieving categories', error: error.message }); } }; /** * ============================================ * GET ALL TOPICS * ============================================ * Lấy danh sách tất cả topics có trong database * * @route GET /api/vocab/meta/topics * @access Private * * INPUT: Không có * * OUTPUT: * { * success: Boolean, * message: String, * data: Array of String - danh sách topics, * count: Number - số lượng topics * } */ exports.getAllTopics = async (req, res) => { try { const topics = await Vocab.findAll({ where: { is_active: true, topic: { [Op.ne]: null } }, attributes: ['topic'], group: ['topic'], order: [['topic', 'ASC']], raw: true }); const topicList = topics.map(t => t.topic); res.json({ success: true, message: 'Topics retrieved successfully', data: topicList, count: topicList.length }); } catch (error) { console.error('Error getting topics:', error); res.status(500).json({ success: false, message: 'Error retrieving topics', error: error.message }); } };