From b7ba1d02b3fd7518a23d807d87e85c2e082013de Mon Sep 17 00:00:00 2001 From: silverpro89 Date: Wed, 18 Feb 2026 18:01:45 +0700 Subject: [PATCH] update --- app.js | 4 +- controllers/contextController.js | 49 +- controllers/storyController.js | 3 +- controllers/vocabController.js | 1680 ++++++++++++++---------------- models/Context.js | 1 - models/Vocab.js | 17 +- public/contexts.html | 43 + routes/contextRoutes.js | 3 + routes/storyRoutes.js | 18 +- routes/vocabRoutes.js | 637 +++++------ 10 files changed, 1207 insertions(+), 1248 deletions(-) diff --git a/app.js b/app.js index 7f7d5c8..913a862 100644 --- a/app.js +++ b/app.js @@ -166,7 +166,7 @@ app.get('/api', (req, res) => { lessons: '/api/lessons', games: '/api/games', gameTypes: '/api/game-types', - vocab: '/api/vocab', + vocabs: '/api/vocabs', contexts: '/api/contexts', contextGuides: '/api/context-guides', upload: '/api/upload', @@ -223,7 +223,7 @@ app.use('/api/chapters', chapterLessonRoutes); // Nested route: /api/chapters/:i app.use('/api/games', gameRoutes); app.use('/api/game-types', gameTypeRoutes); app.use('/api/lessons', lessonRoutes); -app.use('/api/vocab', vocabRoutes); +app.use('/api/vocabs', vocabRoutes); app.use('/api/grammar', grammarRoutes); app.use('/api/stories', storyRoutes); app.use('/api/learning-content', learningContentRoutes); diff --git a/controllers/contextController.js b/controllers/contextController.js index 47ae2fc..fbd272a 100644 --- a/controllers/contextController.js +++ b/controllers/contextController.js @@ -1,4 +1,4 @@ -const { Context } = require('../models'); +const { Context, Vocab } = require('../models'); /** * Context Controller - Workflow-based status management @@ -220,6 +220,28 @@ class ContextController { } } + /** + * Bulk update all status 2 to status 3 (Prompt Ready -> Generating) + */ + async bulkUpdateStatus2To3(req, res, next) { + try { + const [affectedCount] = await Context.update( + { status: 3 }, + { where: { status: 2 } } + ); + + res.json({ + success: true, + message: `Updated ${affectedCount} context(s) from status 2 to status 3`, + data: { + affectedCount + } + }); + } catch (error) { + next(error); + } + } + /** * Add images - Status 3 -> 4 (Image Ready) */ @@ -286,9 +308,28 @@ class ContextController { message: 'Context must be in Image Ready status (4) to approve' }); } - - await context.update({ status: 5 }); - + // add image to Vocab Image + const currentVocab = await Vocab.findOne({ where: { vocab_id: context.reference_id } }); + console.log('Current Vocab:', currentVocab); + if (currentVocab) { + if (context.type_image === 'small') { + const updatedImagesSmall = currentVocab.image_small || []; + updatedImagesSmall.push(context.image); + await currentVocab.update({ image_small: updatedImagesSmall }); + } else if (context.type_image === 'square') { + const updatedImagesSquare = currentVocab.image_square || []; + updatedImagesSquare.push(context.image); + await currentVocab.update({ image_square: updatedImagesSquare }); + } else if (context.type_image === 'normal') { + const updatedImagesNormal = currentVocab.image_normal || []; + updatedImagesNormal.push(context.image); + await currentVocab.update({ image_normal: updatedImagesNormal }); + } + await currentVocab.save(); + } + await context.update({ + status: 5 + }); res.json({ success: true, message: 'Context approved successfully', diff --git a/controllers/storyController.js b/controllers/storyController.js index c795285..d4155f8 100644 --- a/controllers/storyController.js +++ b/controllers/storyController.js @@ -96,8 +96,7 @@ exports.getAllStories = async (req, res) => { where, limit: parseInt(limit), offset, - order: [[sort_by, sort_order.toUpperCase()]], - attributes: ['id', 'name', 'logo', 'grade', 'tag', 'created_at', 'updated_at'] + order: [[sort_by, sort_order.toUpperCase()]] }); res.json({ diff --git a/controllers/vocabController.js b/controllers/vocabController.js index bf44a7f..5fd3274 100644 --- a/controllers/vocabController.js +++ b/controllers/vocabController.js @@ -1,191 +1,123 @@ -const { Vocab, VocabMapping, VocabForm, VocabRelation } = require('../models'); +const { Vocab } = require('../models'); const { Op } = require('sequelize'); /** - * @swagger - * components: - * schemas: - * Vocab: - * type: object - * required: - * - vocab_code - * - base_word - * - translation - * properties: - * vocab_id: - * type: integer - * description: Auto-generated vocab ID - * vocab_code: - * type: string - * description: Unique vocabulary code (e.g., vocab-001-eat) - * base_word: - * type: string - * description: Base form of the word - * translation: - * type: string - * description: Vietnamese translation - * difficulty_score: - * type: integer - * description: Difficulty level (1-10) - * category: - * type: string - * description: Category (e.g., Action Verbs, Nouns) - * images: - * type: array - * items: - * type: string - * description: Array of image URLs - * tags: - * type: array - * items: - * type: string - * description: Array of tags - * VocabComplete: - * allOf: - * - $ref: '#/components/schemas/Vocab' - * - type: object - * properties: - * mappings: - * type: array - * items: - * type: object - * properties: - * book_id: - * type: string - * grade: - * type: integer - * unit: - * type: integer - * lesson: - * type: integer - * form_key: - * type: string - * forms: - * type: object - * additionalProperties: - * type: object - * properties: - * text: - * type: string - * phonetic: - * type: string - * audio: - * type: string - * min_grade: - * type: integer - * relations: - * type: object - * properties: - * synonyms: - * type: array - * items: - * type: string - * antonyms: - * type: array - * items: - * type: string - */ - -/** - * Create a new vocabulary entry with all related data + * ============================================ + * 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) => { - const transaction = await Vocab.sequelize.transaction(); - try { const { - vocab_code, + text, + ipa, base_word, - translation, - attributes = {}, - mappings = [], - forms = {}, - relations = {}, - syntax = {}, - semantics = {}, - constraints = {} + form_key, + vi, + category, + topic, + image_small, + image_square, + image_normal, + audio, + example_sentences, + tags, + syntax, + semantics, + constraints } = req.body; - // 1. Create main vocab entry + // 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({ - vocab_code, + text, + ipa: ipa || null, base_word, - translation, - difficulty_score: attributes.difficulty_score || 1, - category: attributes.category, - images: attributes.images || [], - tags: attributes.tags || [], - syntax: syntax, - semantics: semantics, - constraints: constraints - }, { transaction }); - - // 2. Create curriculum mappings - if (mappings.length > 0) { - const mappingData = mappings.map(m => ({ - vocab_id: vocab.vocab_id, - book_id: m.book_id, - grade: m.grade, - unit: m.unit, - lesson: m.lesson, - form_key: m.form_key, - context_note: m.context_note - })); - await VocabMapping.bulkCreate(mappingData, { transaction }); - } - - // 3. Create word forms - if (Object.keys(forms).length > 0) { - const formData = Object.entries(forms).map(([form_key, formInfo]) => ({ - vocab_id: vocab.vocab_id, - form_key, - text: formInfo.text, - phonetic: formInfo.phonetic, - audio_url: formInfo.audio, - min_grade: formInfo.min_grade || 1, - description: formInfo.description - })); - await VocabForm.bulkCreate(formData, { transaction }); - } - - // 4. Create relations (synonyms and antonyms) - const relationData = []; - if (relations.synonyms && relations.synonyms.length > 0) { - relations.synonyms.forEach(word => { - relationData.push({ - vocab_id: vocab.vocab_id, - relation_type: 'synonym', - related_word: word - }); - }); - } - if (relations.antonyms && relations.antonyms.length > 0) { - relations.antonyms.forEach(word => { - relationData.push({ - vocab_id: vocab.vocab_id, - relation_type: 'antonym', - related_word: word - }); - }); - } - if (relationData.length > 0) { - await VocabRelation.bulkCreate(relationData, { transaction }); - } - - await transaction.commit(); - - // Fetch complete vocab data - const completeVocab = await getCompleteVocab(vocab.vocab_id); + form_key: form_key || 'base', + 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, + is_active: true + }); res.status(201).json({ + success: true, message: 'Vocabulary created successfully', - data: completeVocab + data: vocab }); } catch (error) { - await transaction.rollback(); console.error('Error creating vocab:', error); res.status(500).json({ + success: false, message: 'Error creating vocabulary', error: error.message }); @@ -193,7 +125,38 @@ exports.createVocab = async (req, res) => { }; /** - * Get all vocabulary entries with pagination and filters + * ============================================ + * 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 { @@ -201,83 +164,57 @@ exports.getAllVocabs = async (req, res) => { page = 1, limit = 20, category, - grade, - book_id, - difficulty_min, - difficulty_max, + topic, + base_word, + text, search, - include_relations = 'false' + is_active = true } = req.query; const offset = (page - 1) * limit; - const where = { is_active: true }; + const where = {}; // Apply filters + if (is_active !== undefined) { + where.is_active = is_active === 'true' || is_active === true; + } + if (category) { where.category = category; } - if (difficulty_min || difficulty_max) { - where.difficulty_score = {}; - if (difficulty_min) where.difficulty_score[Op.gte] = parseInt(difficulty_min); - if (difficulty_max) where.difficulty_score[Op.lte] = parseInt(difficulty_max); + 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}%` } }, - { translation: { [Op.like]: `%${search}%` } }, - { vocab_code: { [Op.like]: `%${search}%` } } + { vi: { [Op.like]: `%${search}%` } } ]; } - // Build include array - const include = [ - { - model: VocabMapping, - as: 'mappings', - required: false - }, - { - model: VocabForm, - as: 'forms', - required: false - } - ]; - - if (include_relations === 'true') { - include.push({ - model: VocabRelation, - as: 'relations', - required: false - }); - } - - // Apply grade or book_id filter through mappings - if (grade || book_id) { - const mappingWhere = {}; - if (grade) mappingWhere.grade = parseInt(grade); - if (book_id) mappingWhere.book_id = book_id; - - include[0].where = mappingWhere; - include[0].required = true; - } - const { count, rows } = await Vocab.findAndCountAll({ where, - include, limit: parseInt(limit), offset: parseInt(offset), - order: [['vocab_code', 'ASC']], - distinct: true + order: [['created_at', 'DESC']] }); - // Format response - const formattedVocabs = rows.map(vocab => formatVocabResponse(vocab)); - res.json({ + success: true, message: 'Vocabularies retrieved successfully', - data: formattedVocabs, + data: rows, pagination: { total: count, page: parseInt(page), @@ -289,6 +226,7 @@ exports.getAllVocabs = async (req, res) => { } catch (error) { console.error('Error getting vocabs:', error); res.status(500).json({ + success: false, message: 'Error retrieving vocabularies', error: error.message }); @@ -296,42 +234,54 @@ exports.getAllVocabs = async (req, res) => { }; /** - * Get single vocabulary by ID or code + * ============================================ + * 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; - - // Check if id is numeric (vocab_id) or string (vocab_code) - const where = isNaN(id) - ? { vocab_code: id, is_active: true } - : { vocab_id: parseInt(id), is_active: true }; const vocab = await Vocab.findOne({ - where, - include: [ - { model: VocabMapping, as: 'mappings' }, - { model: VocabForm, as: 'forms' }, - { model: VocabRelation, as: 'relations' } - ] + where: { + vocab_id: id, + is_active: true + } }); if (!vocab) { return res.status(404).json({ + success: false, message: 'Vocabulary not found' }); } - const formattedVocab = formatVocabResponse(vocab); - res.json({ + success: true, message: 'Vocabulary retrieved successfully', - data: formattedVocab + data: vocab }); } catch (error) { console.error('Error getting vocab:', error); res.status(500).json({ + success: false, message: 'Error retrieving vocabulary', error: error.message }); @@ -339,129 +289,214 @@ exports.getVocabById = async (req, res) => { }; /** - * Update vocabulary entry + * ============================================ + * 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) => { - const transaction = await Vocab.sequelize.transaction(); - try { const { id } = req.params; - const { - base_word, - translation, - attributes, - mappings, - forms, - relations, - syntax, - semantics, - constraints - } = req.body; + const updateData = req.body; - // Find vocab - const where = isNaN(id) ? { vocab_code: id } : { vocab_id: parseInt(id) }; - const vocab = await Vocab.findOne({ where }); + const vocab = await Vocab.findOne({ + where: { vocab_id: id } + }); if (!vocab) { - await transaction.rollback(); return res.status(404).json({ + success: false, message: 'Vocabulary not found' }); } - // 1. Update main vocab entry - const updateData = {}; - if (base_word) updateData.base_word = base_word; - if (translation) updateData.translation = translation; - if (attributes) { - if (attributes.difficulty_score !== undefined) updateData.difficulty_score = attributes.difficulty_score; - if (attributes.category !== undefined) updateData.category = attributes.category; - if (attributes.images !== undefined) updateData.images = attributes.images; - if (attributes.tags !== undefined) updateData.tags = attributes.tags; - } - if (syntax !== undefined) updateData.syntax = syntax; - if (semantics !== undefined) updateData.semantics = semantics; - if (constraints !== undefined) updateData.constraints = constraints; - - if (Object.keys(updateData).length > 0) { - await vocab.update(updateData, { transaction }); - } - - // 2. Update mappings (replace all) - if (mappings !== undefined) { - await VocabMapping.destroy({ where: { vocab_id: vocab.vocab_id }, transaction }); - if (mappings.length > 0) { - const mappingData = mappings.map(m => ({ - vocab_id: vocab.vocab_id, - book_id: m.book_id, - grade: m.grade, - unit: m.unit, - lesson: m.lesson, - form_key: m.form_key, - context_note: m.context_note - })); - await VocabMapping.bulkCreate(mappingData, { transaction }); - } - } - - // 3. Update forms (replace all) - if (forms !== undefined) { - await VocabForm.destroy({ where: { vocab_id: vocab.vocab_id }, transaction }); - if (Object.keys(forms).length > 0) { - const formData = Object.entries(forms).map(([form_key, formInfo]) => ({ - vocab_id: vocab.vocab_id, - form_key, - text: formInfo.text, - phonetic: formInfo.phonetic, - audio_url: formInfo.audio, - min_grade: formInfo.min_grade || 1, - description: formInfo.description - })); - await VocabForm.bulkCreate(formData, { transaction }); - } - } - - // 4. Update relations (replace all) - if (relations !== undefined) { - await VocabRelation.destroy({ where: { vocab_id: vocab.vocab_id }, transaction }); - const relationData = []; - if (relations.synonyms && relations.synonyms.length > 0) { - relations.synonyms.forEach(word => { - relationData.push({ - vocab_id: vocab.vocab_id, - relation_type: 'synonym', - related_word: word - }); - }); - } - if (relations.antonyms && relations.antonyms.length > 0) { - relations.antonyms.forEach(word => { - relationData.push({ - vocab_id: vocab.vocab_id, - relation_type: 'antonym', - related_word: word - }); - }); - } - if (relationData.length > 0) { - await VocabRelation.bulkCreate(relationData, { transaction }); - } - } - - await transaction.commit(); - - // Fetch updated vocab - const updatedVocab = await getCompleteVocab(vocab.vocab_id); + // Update vocab + await vocab.update(updateData); res.json({ + success: true, message: 'Vocabulary updated successfully', - data: updatedVocab + data: vocab }); } catch (error) { - await transaction.rollback(); console.error('Error updating vocab:', error); res.status(500).json({ + success: false, message: 'Error updating vocabulary', error: error.message }); @@ -469,30 +504,52 @@ exports.updateVocab = async (req, res) => { }; /** - * Delete vocabulary (soft delete) + * ============================================ + * 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 where = isNaN(id) ? { vocab_code: id } : { vocab_id: parseInt(id) }; - const vocab = await Vocab.findOne({ where }); + + 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 }); @@ -500,71 +557,127 @@ exports.deleteVocab = async (req, res) => { }; /** - * Get vocabularies by curriculum (book, grade, unit, lesson) + * ============================================ + * 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.getVocabsByCurriculum = async (req, res) => { +exports.bulkCreateVocabs = async (req, res) => { try { - const { book_id, grade, unit, lesson } = req.query; + const { vocabs } = req.body; - if (!book_id && !grade) { + if (!Array.isArray(vocabs) || vocabs.length === 0) { return res.status(400).json({ - message: 'At least book_id or grade is required' + success: false, + message: 'vocabs must be a non-empty array' }); } - const mappingWhere = {}; - if (book_id) mappingWhere.book_id = book_id; - if (grade) mappingWhere.grade = parseInt(grade); - if (unit) mappingWhere.unit = parseInt(unit); - if (lesson) mappingWhere.lesson = parseInt(lesson); + // 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 vocabs = await Vocab.findAll({ - where: { is_active: true }, - include: [ - { - model: VocabMapping, - as: 'mappings', - where: mappingWhere, - required: true - }, - { - model: VocabForm, - as: 'forms' - }, - { - model: VocabRelation, - as: 'relations' - } - ], - order: [['vocab_code', 'ASC']] - }); + const createdVocabs = await Vocab.bulkCreate(vocabs); - const formattedVocabs = vocabs.map(vocab => formatVocabResponse(vocab)); - - res.json({ - message: 'Vocabularies retrieved successfully', - data: formattedVocabs, - count: formattedVocabs.length + res.status(201).json({ + success: true, + message: `${createdVocabs.length} vocabularies created successfully`, + data: createdVocabs, + count: createdVocabs.length }); } catch (error) { - console.error('Error getting vocabs by curriculum:', error); + console.error('Error bulk creating vocabs:', error); res.status(500).json({ - message: 'Error retrieving vocabularies', + success: false, + message: 'Error creating vocabularies', error: error.message }); } }; /** - * Get vocabulary statistics + * ============================================ + * 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 vocabs - const totalVocabs = await Vocab.count({ where: { is_active: true } }); + // 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({ @@ -574,45 +687,48 @@ exports.getVocabStats = async (req, res) => { [sequelize.fn('COUNT', sequelize.col('vocab_id')), 'count'] ], group: ['category'], + order: [[sequelize.fn('COUNT', sequelize.col('vocab_id')), 'DESC']], raw: true }); - // By difficulty - const byDifficulty = await Vocab.findAll({ + // By topic + const byTopic = await Vocab.findAll({ where: { is_active: true }, attributes: [ - 'difficulty_score', + 'topic', [sequelize.fn('COUNT', sequelize.col('vocab_id')), 'count'] ], - group: ['difficulty_score'], - order: [['difficulty_score', 'ASC']], + group: ['topic'], + order: [[sequelize.fn('COUNT', sequelize.col('vocab_id')), 'DESC']], raw: true }); - // By grade (from mappings) - const byGrade = await VocabMapping.findAll({ - attributes: [ - 'grade', - [sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('vocab_id'))), 'count'] - ], - group: ['grade'], - order: [['grade', 'ASC']], - raw: true + // Count unique base words + const uniqueBaseWords = await Vocab.count({ + where: { is_active: true }, + distinct: true, + col: 'base_word' }); res.json({ - message: 'Vocabulary statistics retrieved successfully', + success: true, + message: 'Statistics retrieved successfully', data: { - total: totalVocabs, + total: { + active: totalActive, + inactive: totalInactive, + all: totalActive + totalInactive + }, + unique_base_words: uniqueBaseWords, by_category: byCategory, - by_difficulty: byDifficulty, - by_grade: byGrade + 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 }); @@ -620,527 +736,287 @@ exports.getVocabStats = async (req, res) => { }; /** - * Get comprehensive guide for AI to create vocabulary entries + * ============================================ + * 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.getVocabGuide = async (req, res) => { +exports.searchVocabs = async (req, res) => { try { - const guide = { - guide_version: "2.0.0", - last_updated: "2026-01-26", - description: "Comprehensive guide for AI to understand and create vocabulary entries for Grammar Engine", - - data_structure: { - required_fields: { - vocab_code: { - type: "string", - format: "vocab-{sequence}-{base_word}", - example: "vocab-001-eat", - rule: "Must be unique, use 3-digit sequence number" - }, - base_word: { - type: "string", - example: "eat", - rule: "Base form of the word in English" - }, - translation: { - type: "string", - example: "ăn", - rule: "Vietnamese translation" - } - }, - - optional_fields: { - attributes: { - difficulty_score: { - type: "integer", - range: "1-10", - default: 1, - guide: "1-2: Basic, 3-4: Intermediate, 5-6: Advanced, 7-8: Difficult, 9-10: Expert" - }, - category: { - type: "string", - options: ["Action Verbs", "Nouns", "Adjectives", "Adverbs", "Articles", "Pronouns", "Prepositions", "Conjunctions"], - example: "Action Verbs" - }, - images: { - type: "array", - item_type: "string (URL)", - example: ["https://cdn.sena.tech/img/eat-main.png"] - }, - tags: { - type: "array", - item_type: "string", - example: ["daily-routine", "verb", "food"] - } - }, - - mappings: { - type: "array of objects", - description: "Curriculum mapping - where this word appears in textbooks", - fields: { - book_id: { type: "string", example: "global-success-1" }, - grade: { type: "integer", example: 1 }, - unit: { type: "integer", example: 2 }, - lesson: { type: "integer", example: 3 }, - form_key: { type: "string", example: "v1", description: "Which form to use at this point" }, - context_note: { type: "string", optional: true } - } - }, - - forms: { - type: "object", - description: "Different grammatical forms of the word", - structure: { - "{form_key}": { - text: { type: "string", example: "eat" }, - phonetic: { type: "string", format: "IPA", example: "/iːt/" }, - audio: { type: "string", format: "URL", example: "https://cdn.sena.tech/audio/eat_v1.mp3" }, - min_grade: { type: "integer", example: 1, description: "Minimum grade to unlock" }, - description: { type: "string", optional: true } - } - } - }, - - relations: { - type: "object", - fields: { - synonyms: { type: "array", example: ["consume", "dine"] }, - antonyms: { type: "array", example: ["fast", "starve"] }, - related: { type: "array", example: ["food", "meal"], optional: true } - } - }, - - syntax: { - type: "object", - description: "Syntax roles for Grammar Engine", - critical: true, - fields: { - is_subject: { type: "boolean", description: "Can be used as subject" }, - is_verb: { type: "boolean", description: "Is a verb" }, - is_object: { type: "boolean", description: "Can be used as object" }, - is_be: { type: "boolean", description: "Is 'be' verb" }, - is_adj: { type: "boolean", description: "Is adjective" }, - is_adv: { type: "boolean", description: "Is adverb" }, - is_article: { type: "boolean", description: "Is article (a/an/the)" }, - is_pronoun: { type: "boolean", description: "Is pronoun" }, - is_preposition: { type: "boolean", description: "Is preposition" }, - verb_type: { type: "string", options: ["transitive", "intransitive", "linking"], when: "is_verb=true" }, - article_type: { type: "string", options: ["definite", "indefinite"], when: "is_article=true" }, - adv_type: { type: "string", options: ["manner", "frequency", "degree", "time", "place"], when: "is_adv=true" }, - position: { type: "string", description: "Word position in sentence" }, - priority: { type: "integer", description: "Selection priority for Grammar Engine" }, - person: { type: "string", options: ["first", "second", "third"], when: "is_pronoun=true" }, - number: { type: "string", options: ["singular", "plural"], when: "is_pronoun=true" } - } - }, - - semantics: { - type: "object", - description: "Semantic constraints to ensure meaningful sentences", - critical: true, - fields: { - can_be_subject_type: { - type: "array", - options: ["human", "animal", "object", "food", "plant", "abstract", "place", "time"], - when: "is_verb=true", - description: "What types can be subject with this verb" - }, - can_take_object_type: { - type: "array", - options: ["human", "animal", "object", "food", "plant", "abstract", "place", "time"], - when: "verb_type=transitive", - description: "What types this verb can take as object" - }, - can_modify: { - type: "array", - options: ["action_verb", "stative_verb", "be_verb", "adjective", "adverb", "noun"], - when: "is_adv=true", - description: "What this adverb can modify" - }, - cannot_modify: { - type: "array", - options: ["action_verb", "stative_verb", "be_verb", "adjective", "adverb", "noun"], - when: "is_adv=true", - description: "What this adverb cannot modify" - }, - word_type: { - type: "string", - options: ["action", "state", "entity", "property", "concept", "relation"], - required: true, - description: "Semantic type of the word" - }, - is_countable: { - type: "boolean", - when: "is_object=true", - description: "Can this noun be counted" - }, - person_type: { - type: "string", - options: ["1st", "2nd", "3rd"], - when: "is_pronoun=true" - } - } - }, - - constraints: { - type: "object", - description: "Grammar constraints for word combination", - fields: { - followed_by: { - type: "string", - options: ["vowel_sound", "consonant_sound", "any"], - when: "is_article=true", - description: "What sound type must follow" - }, - match_subject: { - type: "object", - when: "is_be=true", - example: { "I": "am", "he": "is", "you": "are" }, - description: "Subject-verb agreement rules" - }, - match_with: { - type: "string", - description: "Must match with specific word type" - }, - position_rules: { - type: "array", - description: "Possible positions in sentence" - }, - requires_object: { - type: "boolean", - when: "verb_type=transitive" - } - } - } - } - }, - - form_keys_reference: { - verbs: { - v1: "Base form (eat, run)", - v_s_es: "Third person singular (eats, runs)", - v_ing: "Present participle (eating, running)", - v2: "Past simple (ate, ran)", - v3: "Past participle (eaten, run)" - }, - nouns: { - n_singular: "Singular form (cat, apple)", - n_plural: "Plural form (cats, apples)" - }, - adjectives: { - adj_base: "Base form (big, happy)", - adj_comparative: "Comparative (bigger, happier)", - adj_superlative: "Superlative (biggest, happiest)" - }, - adverbs: { - adv_base: "Base form (quickly, slowly)", - adv_comparative: "Comparative (more quickly)", - adv_superlative: "Superlative (most quickly)" - }, - pronouns: { - subject: "Subject pronoun (I, you, he)", - object: "Object pronoun (me, you, him)" - } - }, - - rules: { - vocab_code_format: "Must follow pattern: vocab-{3-digit-number}-{base_word}", - phonetic_format: "Use IPA notation enclosed in /slashes/", - audio_url_format: "Must be valid HTTPS URL pointing to MP3 or OGG file", - - semantic_compatibility: { - rule: "Ensure semantic constraints create meaningful sentences", - examples: { - valid: "human eats food (human can eat, food can be eaten)", - invalid: "table eats book (table cannot eat, book is not food)" - } - }, - - article_selection: { - rule: "Use phonetic to determine a/an", - algorithm: "Check first phoneme: if vowel sound use 'an', else use 'a'", - examples: { - an: "apple (/ˈæp.əl/ starts with /æ/)", - a: "cat (/kæt/ starts with /k/)" - } - }, - - be_verb_agreement: { - rule: "Match be verb form with subject", - mapping: { - "I": "am", - "you": "are", - "he/she/it": "is", - "we/they": "are" - } - } - }, - - examples: { - transitive_verb: { - vocab_code: "vocab-001-eat", - base_word: "eat", - translation: "ăn", - attributes: { - difficulty_score: 1, - category: "Action Verbs", - tags: ["verb", "action", "daily-routine"] - }, - forms: { - v1: { text: "eat", phonetic: "/iːt/", min_grade: 1 }, - v_s_es: { text: "eats", phonetic: "/iːts/", min_grade: 2 }, - v_ing: { text: "eating", phonetic: "/ˈiː.tɪŋ/", min_grade: 2 }, - v2: { text: "ate", phonetic: "/eɪt/", min_grade: 3 } - }, - syntax: { - is_verb: true, - verb_type: "transitive", - is_subject: false, - is_object: false - }, - semantics: { - can_be_subject_type: ["human", "animal"], - can_take_object_type: ["food", "plant"], - word_type: "action" - }, - constraints: { - requires_object: true - } - }, - - article: { - vocab_code: "vocab-art-01", - base_word: "a", - translation: "một (mạo từ bất định)", - attributes: { - difficulty_score: 1, - category: "Articles", - tags: ["article", "function-word", "grammar"] - }, - forms: { - base: { text: "a", phonetic: "/ə/", min_grade: 1 } - }, - syntax: { - is_article: true, - article_type: "indefinite", - priority: 1 - }, - constraints: { - followed_by: "consonant_sound" - } - }, - - adverb_manner: { - vocab_code: "vocab-adv-01", - base_word: "quickly", - translation: "nhanh chóng", - attributes: { - difficulty_score: 2, - category: "Adverbs", - tags: ["adverb", "manner"] - }, - forms: { - base: { text: "quickly", phonetic: "/ˈkwɪk.li/", min_grade: 2 } - }, - syntax: { - is_adv: true, - adv_type: "manner", - position: "after_verb" - }, - semantics: { - can_modify: ["action_verb"], - cannot_modify: ["stative_verb", "be_verb"], - word_type: "property" - } - }, - - noun: { - vocab_code: "vocab-200-apple", - base_word: "apple", - translation: "quả táo", - attributes: { - difficulty_score: 1, - category: "Nouns", - tags: ["noun", "food", "fruit"] - }, - forms: { - n_singular: { text: "apple", phonetic: "/ˈæp.əl/", min_grade: 1 }, - n_plural: { text: "apples", phonetic: "/ˈæp.əlz/", min_grade: 1 } - }, - syntax: { - is_subject: true, - is_object: true, - is_verb: false - }, - semantics: { - word_type: "entity", - is_countable: true - } - }, - - pronoun: { - vocab_code: "vocab-pron-01", - base_word: "I", - translation: "tôi", - attributes: { - difficulty_score: 1, - category: "Pronouns", - tags: ["pronoun", "personal"] - }, - forms: { - subject: { text: "I", phonetic: "/aɪ/", min_grade: 1 }, - object: { text: "me", phonetic: "/miː/", min_grade: 1 } - }, - syntax: { - is_subject: true, - is_pronoun: true, - pronoun_type: "personal", - person: "first", - number: "singular" - }, - semantics: { - person_type: "1st", - word_type: "entity" - }, - constraints: { - match_subject: { "I": "am" } - } - } - }, - - validation_checklist: { - before_submit: [ - "✓ vocab_code follows format vocab-XXX-{word}", - "✓ All phonetic notations use IPA format with /slashes/", - "✓ At least one form is defined in 'forms' object", - "✓ syntax object has at least one role set to true", - "✓ semantics.word_type is specified", - "✓ If is_verb=true, verb_type is specified", - "✓ If verb_type=transitive, can_take_object_type is specified", - "✓ If is_article=true, constraints.followed_by is specified", - "✓ If is_adv=true, can_modify array is specified" - ] - }, - - common_mistakes: [ - { - mistake: "Not setting any syntax role", - fix: "Set at least one is_{role} to true" - }, - { - mistake: "Using 'a' before vowel sound words", - fix: "Check phonetic - if starts with vowel sound, use 'an'" - }, - { - mistake: "Transitive verb without can_take_object_type", - fix: "Specify what types this verb can take as object" - }, - { - mistake: "Missing word_type in semantics", - fix: "Always specify: action, state, entity, property, concept, or relation" - }, - { - mistake: "Incorrect phonetic format", - fix: "Use IPA notation: /iːt/ not 'eet' or 'eat'" - } - ], - - ai_tips: { - efficiency: "Create related words together (eat, eats, eating) to maintain consistency", - accuracy: "Double-check phonetic transcription using Cambridge Dictionary or similar", - completeness: "Fill all relevant fields - more data means better Grammar Engine performance", - testing: "After creating words, test sentence generation to verify semantic constraints work", - documentation: "Use descriptive context_note in mappings to help future AI understand usage" + const { + topic, + category, + base_word, + form_key, + text, + vi, + 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}%` }; + } + + // 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({ - message: 'Vocabulary guide retrieved successfully', - data: guide + 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 getting vocab guide:', error); + console.error('Error searching vocabs:', error); res.status(500).json({ - message: 'Error retrieving guide', + success: false, + message: 'Error searching vocabularies', error: error.message }); } }; -// Helper functions - /** - * Get complete vocabulary data with all relations + * ============================================ + * 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 + * } */ -async function getCompleteVocab(vocab_id) { - const vocab = await Vocab.findByPk(vocab_id, { - include: [ - { model: VocabMapping, as: 'mappings' }, - { model: VocabForm, as: 'forms' }, - { model: VocabRelation, as: 'relations' } - ] - }); - return formatVocabResponse(vocab); -} +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 + }); -/** - * Format vocabulary response to match the expected structure - */ -function formatVocabResponse(vocab) { - const vocabJson = vocab.toJSON(); - - // Format attributes - const attributes = { - difficulty_score: vocabJson.difficulty_score, - category: vocabJson.category, - images: vocabJson.images || [], - tags: vocabJson.tags || [] - }; + const categoryList = categories.map(c => c.category); - // Format forms as object keyed by form_key - const forms = {}; - if (vocabJson.forms) { - vocabJson.forms.forEach(form => { - forms[form.form_key] = { - text: form.text, - phonetic: form.phonetic, - audio: form.audio_url, - min_grade: form.min_grade, - description: form.description - }; + 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 }); } +}; - // Format relations grouped by type - const relations = { - synonyms: [], - antonyms: [], - related: [] - }; - if (vocabJson.relations) { - vocabJson.relations.forEach(rel => { - if (rel.relation_type === 'synonym') { - relations.synonyms.push(rel.related_word); - } else if (rel.relation_type === 'antonym') { - relations.antonyms.push(rel.related_word); - } else if (rel.relation_type === 'related') { - relations.related.push(rel.related_word); - } +/** + * ============================================ + * 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 }); } - - return { - id: vocabJson.vocab_code, - vocab_id: vocabJson.vocab_id, - base_word: vocabJson.base_word, - translation: vocabJson.translation, - attributes, - mappings: vocabJson.mappings || [], - forms, - relations, - syntax: vocabJson.syntax || {}, - semantics: vocabJson.semantics || {}, - constraints: vocabJson.constraints || {} - }; -} +}; diff --git a/models/Context.js b/models/Context.js index c45851d..772e427 100644 --- a/models/Context.js +++ b/models/Context.js @@ -1,6 +1,5 @@ const { DataTypes } = require('sequelize'); const { sequelize } = require('../config/database'); -const { ref } = require('joi'); const Context = sequelize.define('Context', { uuid: { diff --git a/models/Vocab.js b/models/Vocab.js index 4532864..7e1c6dc 100644 --- a/models/Vocab.js +++ b/models/Vocab.js @@ -24,16 +24,9 @@ const Vocab = sequelize.define('Vocab', { allowNull: false, index: true }, - // Đã xuất hiện trong khối nào, bài học nào, lesson nào - // Ví dụ 111 là grade 1, unit 1, lesson 1 - grade: { - type: DataTypes.INTEGER, - defaultValue: 0, - comment: 'It is number of gradeX100 + unitX10 + lesson (e.g., Grade 1 Unit 2 Lesson 3 = 123)' - }, // Loại biến thể (V1, V2, V3, V_ing, Noun_Form...) form_key: { - type: DataTypes.JSON, + type: DataTypes.TEXT, defaultValue: 'base', comment: 'Form key indicating the type of word form (e.g., base, V1, V2, V3, V_ing, Noun_Form)' }, @@ -47,10 +40,6 @@ const Vocab = sequelize.define('Vocab', { type: DataTypes.STRING(100), comment: 'Category of the word (e.g., Action Verbs, Nouns)' }, - etc : { - type: DataTypes.TEXT, - comment: 'Book or additional reference' - }, topic: { type: DataTypes.STRING(100), comment: 'Topic of the word (e.g., Food, Travel, Education)' @@ -121,10 +110,6 @@ const Vocab = sequelize.define('Vocab', { { name: 'idx_category', fields: ['category'] - }, - { - name: 'idx_grade', - fields: ['grade'] } ] }); diff --git a/public/contexts.html b/public/contexts.html index da2a631..3ed51d6 100644 --- a/public/contexts.html +++ b/public/contexts.html @@ -359,6 +359,16 @@

📋 Get Contexts by Status Status: 2

+ +
+ + +
+ +
+
@@ -382,6 +392,14 @@
+ +
+ +

🚀 Bulk Update

+ +
@@ -643,6 +661,31 @@ } } + async function bulkUpdateStatus2To3() { + const headers = getHeaders(); + + if (!confirm('Are you sure you want to update ALL contexts from status 2 to status 3?')) { + return; + } + + try { + const response = await fetch(`${API_BASE}/bulk/status-2-to-3`, { + method: 'POST', + headers: headers + }); + const data = await response.json(); + showResult('bulkUpdateResult', data, response.ok); + showResult('bulkUpdateResultTop', data, response.ok); + if (response.ok) { + getContextsByStatus(3); + getContextsByStatus(2); + } + } catch (error) { + showResult('bulkUpdateResult', { error: error.message }, false); + showResult('bulkUpdateResultTop', { error: error.message }, false); + } + } + async function addImages() { const headers = getHeaders(); diff --git a/routes/contextRoutes.js b/routes/contextRoutes.js index 2aa8cc7..fb5a8ef 100644 --- a/routes/contextRoutes.js +++ b/routes/contextRoutes.js @@ -23,6 +23,9 @@ router.post('/:id/prepare-prompt', contextController.preparePrompt); // Status 2 -> 3 or 1: Update status router.post('/:id/update-status', contextController.updateStatusFromPromptReady); +// Bulk update all status 2 to status 3 +router.post('/bulk/status-2-to-3', contextController.bulkUpdateStatus2To3); + // Status 3 -> 4: Add images router.post('/:id/add-images', contextController.addImages); diff --git a/routes/storyRoutes.js b/routes/storyRoutes.js index 5db5ba4..2e4bbf7 100644 --- a/routes/storyRoutes.js +++ b/routes/storyRoutes.js @@ -78,7 +78,7 @@ const { authenticateToken } = require('../middleware/auth'); * 500: * description: Server error */ -router.post('/', authenticateToken, storyController.createStory); +router.post('/', storyController.createStory); /** * @swagger @@ -135,7 +135,7 @@ router.post('/', authenticateToken, storyController.createStory); * 500: * description: Server error */ -router.get('/', authenticateToken, storyController.getAllStories); +router.get('/', storyController.getAllStories); /** * @swagger @@ -160,7 +160,7 @@ router.get('/', authenticateToken, storyController.getAllStories); * 500: * description: Server error */ -router.get('/grade', authenticateToken, storyController.getStoriesByGrade); +router.get('/grade', storyController.getStoriesByGrade); /** * @swagger @@ -185,7 +185,7 @@ router.get('/grade', authenticateToken, storyController.getStoriesByGrade); * 500: * description: Server error */ -router.get('/tag', authenticateToken, storyController.getStoriesByTag); +router.get('/tag', storyController.getStoriesByTag); /** * @swagger @@ -228,7 +228,7 @@ router.get('/tag', authenticateToken, storyController.getStoriesByTag); * 500: * description: Server error */ -router.get('/guide', authenticateToken, storyController.getStoryGuide); +router.get('/guide', storyController.getStoryGuide); /** * @swagger @@ -244,7 +244,7 @@ router.get('/guide', authenticateToken, storyController.getStoryGuide); * 500: * description: Server error */ -router.get('/stats', authenticateToken, storyController.getStoryStats); +router.get('/stats', storyController.getStoryStats); /** * @swagger @@ -269,7 +269,7 @@ router.get('/stats', authenticateToken, storyController.getStoryStats); * 500: * description: Server error */ -router.get('/:id', authenticateToken, storyController.getStoryById); +router.get('/:id', storyController.getStoryById); /** * @swagger @@ -303,7 +303,7 @@ router.get('/:id', authenticateToken, storyController.getStoryById); * 500: * description: Server error */ -router.put('/:id', authenticateToken, storyController.updateStory); +router.put('/:id', storyController.updateStory); /** * @swagger @@ -328,6 +328,6 @@ router.put('/:id', authenticateToken, storyController.updateStory); * 500: * description: Server error */ -router.delete('/:id', authenticateToken, storyController.deleteStory); +router.delete('/:id', storyController.deleteStory); module.exports = router; diff --git a/routes/vocabRoutes.js b/routes/vocabRoutes.js index 446406c..9f3ec53 100644 --- a/routes/vocabRoutes.js +++ b/routes/vocabRoutes.js @@ -4,334 +4,347 @@ const vocabController = require('../controllers/vocabController'); const { authenticateToken } = require('../middleware/auth'); /** - * @swagger - * tags: - * name: Vocabulary - * description: Vocabulary management system for curriculum-based language learning - */ + * ============================================ + * POST /api/vocabs + * ============================================ + * Tạo một vocab entry mới + * + * 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 + * semantics: JSON - ràng buộc ngữ nghĩa + * constraints: JSON - ràng buộc ngữ pháp + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Vocab object đã tạo (bao gồm vocab_id, created_at, updated_at) + * } + **/ + +router.post('/', vocabController.createVocab); /** - * @swagger - * /api/vocab: - * post: - * summary: Create a new vocabulary entry - * tags: [Vocabulary] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/VocabComplete' - * example: - * vocab_code: "vocab-001-eat" - * base_word: "eat" - * translation: "ăn" - * attributes: - * difficulty_score: 1 - * category: "Action Verbs" - * images: - * - "https://cdn.sena.tech/img/eat-main.png" - * - "https://cdn.sena.tech/img/eat-context.jpg" - * tags: ["daily-routine", "verb"] - * mappings: - * - book_id: "global-success-1" - * grade: 1 - * unit: 2 - * lesson: 3 - * form_key: "v1" - * - book_id: "global-success-2" - * grade: 2 - * unit: 5 - * lesson: 1 - * form_key: "v_ing" - * forms: - * v1: - * text: "eat" - * phonetic: "/iːt/" - * audio: "https://cdn.sena.tech/audio/eat_v1.mp3" - * min_grade: 1 - * v_s_es: - * text: "eats" - * phonetic: "/iːts/" - * audio: "https://cdn.sena.tech/audio/eats_s.mp3" - * min_grade: 2 - * v_ing: - * text: "eating" - * phonetic: "/ˈiː.tɪŋ/" - * audio: "https://cdn.sena.tech/audio/eating_ing.mp3" - * min_grade: 2 - * v2: - * text: "ate" - * phonetic: "/et/" - * audio: "https://cdn.sena.tech/audio/ate_v2.mp3" - * min_grade: 3 - * relations: - * synonyms: ["consume", "dine"] - * antonyms: ["fast", "starve"] - * syntax: - * is_subject: false - * is_verb: true - * is_object: false - * is_be: false - * is_adj: false - * verb_type: "transitive" - * semantics: - * can_be_subject_type: ["human", "animal"] - * can_take_object_type: ["food", "plant"] - * word_type: "action" - * constraints: - * requires_object: true - * semantic_object_types: ["food", "plant"] - * responses: - * 201: - * description: Vocabulary created successfully - * 400: - * description: Invalid input - * 500: - * description: Server error - */ -router.post('/', authenticateToken, vocabController.createVocab); + * ============================================ + * POST /api/vocabs/bulk + * ============================================ + * Tạo nhiều vocab entries cùng lúc + * + * INPUT: + * { + * vocabs: Array of Vocab objects - mỗi object phải có text và base_word + * [ + * { + * text: String (required), + * base_word: String (required), + * ipa: String, + * vi: String, + * ... + * }, + * ... + * ] + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of created Vocab objects, + * count: Number - số lượng vocab đã tạo + * } + **/ +router.post('/bulk', vocabController.bulkCreateVocabs); /** - * @swagger - * /api/vocab: - * get: - * summary: Get all vocabulary entries with pagination and filters - * tags: [Vocabulary] - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: page - * schema: - * type: integer - * default: 1 - * description: Page number - * - in: query - * name: limit - * schema: - * type: integer - * default: 20 - * description: Items per page - * - in: query - * name: category - * schema: - * type: string - * description: Filter by category (e.g., "Action Verbs") - * - in: query - * name: grade - * schema: - * type: integer - * description: Filter by grade level - * - in: query - * name: book_id - * schema: - * type: string - * description: Filter by book ID (e.g., "global-success-1") - * - in: query - * name: difficulty_min - * schema: - * type: integer - * description: Minimum difficulty score - * - in: query - * name: difficulty_max - * schema: - * type: integer - * description: Maximum difficulty score - * - in: query - * name: search - * schema: - * type: string - * description: Search in base_word, translation, or vocab_code - * - in: query - * name: include_relations - * schema: - * type: string - * enum: ['true', 'false'] - * default: 'false' - * description: Include synonyms/antonyms in response - * responses: - * 200: - * description: List of vocabularies - * 500: - * description: Server error - */ + * ============================================ + * POST /api/vocabs/search + * ============================================ + * Tìm kiếm vocab nâng cao với nhiều filter + * + * INPUT: + * { + * topic: String (optional) - chủ đề (exact match) + * category: String (optional) - loại từ (exact match) + * base_word: String (optional) - từ gốc (partial match với LIKE) + * form_key: JSON (optional) - loại biến thể (V1, V2, V3, V_ing, Noun_Form, etc.) + * text: String (optional) - từ thực tế (partial match với LIKE) + * vi: String (optional) - nghĩa tiếng Việt (partial match với LIKE) + * + * 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 + * } + * } + **/ +router.post('/search', vocabController.searchVocabs); + +/** + * ============================================ + * GET /api/vocabs + * ============================================ + * Lấy danh sách tất cả vocab với phân trang và filter + * + * 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, + * page: Number, + * limit: Number, + * totalPages: Number + * } + * } + * + **/ router.get('/', vocabController.getAllVocabs); /** - * @swagger - * /api/vocab/curriculum: - * get: - * summary: Get vocabularies by curriculum mapping - * tags: [Vocabulary] - * security: - * - bearerAuth: [] - * parameters: - * - in: query - * name: book_id - * required: false - * schema: - * type: string - * description: Book ID (e.g., "global-success-1") - * - in: query - * name: grade - * required: false - * schema: - * type: integer - * description: Grade level - * - in: query - * name: unit - * schema: - * type: integer - * description: Unit number - * - in: query - * name: lesson - * schema: - * type: integer - * description: Lesson number - * responses: - * 200: - * description: List of vocabularies for the specified curriculum - * 400: - * description: Invalid parameters - * 500: - * description: Server error - */ -router.get('/curriculum', authenticateToken, vocabController.getVocabsByCurriculum); + * ============================================ + * GET /api/vocabs/stats/overview + * ============================================ + * Lấy thống kê tổng quan về vocab + * + * INPUT: Không có + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: { + * total: { + * active: Number, + * inactive: Number, + * all: Number + * }, + * unique_base_words: Number, + * by_category: Array [{category: String, count: Number}], + * by_topic: Array [{topic: String, count: Number}] + * } + * } + **/ +router.get('/stats/overview', vocabController.getVocabStats); /** - * @swagger - * /api/vocab/guide: - * get: - * summary: Get comprehensive guide for AI to create vocabulary entries - * tags: [Vocabulary] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Complete guide with rules, examples, and data structures - * content: - * application/json: - * schema: - * type: object - * properties: - * guide_version: - * type: string - * last_updated: - * type: string - * data_structure: - * type: object - * rules: - * type: object - * examples: - * type: object - * 500: - * description: Server error - */ -router.get('/guide', authenticateToken, vocabController.getVocabGuide); + * ============================================ + * GET /api/vocabs/meta/categories + * ============================================ + * Lấy danh sách tất cả categories + * + * INPUT: Không có + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of String - danh sách categories, + * count: Number - số lượng categories + * } + **/ +router.get('/meta/categories', vocabController.getAllCategories); /** - * @swagger - * /api/vocab/stats: - * get: - * summary: Get vocabulary statistics - * tags: [Vocabulary] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Vocabulary statistics - * 500: - * description: Server error - */ -router.get('/stats', authenticateToken, vocabController.getVocabStats); + * ============================================ + * GET /api/vocabs/meta/topics + * ============================================ + * Lấy danh sách tất cả topics + * + * INPUT: Không có + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of String - danh sách topics, + * count: Number - số lượng topics + * } + **/ +router.get('/meta/topics', vocabController.getAllTopics); /** - * @swagger - * /api/vocab/{id}: - * get: - * summary: Get vocabulary by ID or code - * tags: [Vocabulary] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * description: Vocabulary ID (numeric) or vocab_code (string) - * responses: - * 200: - * description: Vocabulary details - * 404: - * description: Vocabulary not found - * 500: - * description: Server error - */ -router.get('/:id', authenticateToken, vocabController.getVocabById); + * ============================================ + * GET /api/vocabs/missing/ipa + * ============================================ + * Lấy tất cả các vocab chưa có IPA + * + * 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 + * } + * } + **/ +router.get('/missing/ipa', vocabController.getVocabsWithoutIpa); /** - * @swagger - * /api/vocab/{id}: - * put: - * summary: Update vocabulary entry - * tags: [Vocabulary] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * description: Vocabulary ID (numeric) or vocab_code (string) - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/VocabComplete' - * example: - * translation: "ăn uống" - * attributes: - * difficulty_score: 2 - * tags: ["daily-routine", "verb", "food"] - * responses: - * 200: - * description: Vocabulary updated successfully - * 404: - * description: Vocabulary not found - * 500: - * description: Server error - */ -router.put('/:id', authenticateToken, vocabController.updateVocab); + * ============================================ + * GET /api/vocabs/missing/images + * ============================================ + * Lấy tất cả các vocab chưa đủ hình ảnh + * + * 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 + * } + * } + **/ +router.get('/missing/images', vocabController.getVocabsWithoutImages); /** - * @swagger - * /api/vocab/{id}: - * delete: - * summary: Delete vocabulary (soft delete) - * tags: [Vocabulary] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * description: Vocabulary ID (numeric) or vocab_code (string) - * responses: - * 200: - * description: Vocabulary deleted successfully - * 404: - * description: Vocabulary not found - * 500: - * description: Server error - */ -router.delete('/:id', authenticateToken, vocabController.deleteVocab); + * ============================================ + * GET /api/vocabs/:id + * ============================================ + * Lấy chi tiết một vocab theo ID + * + * 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 + * } + **/ +router.get('/:id', vocabController.getVocabById); + +/** + * ============================================ + * PUT /api/vocabs/:id + * ============================================ + * Cập nhật thông tin vocab + * + * 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, + * 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 + * } + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Updated Vocab object + * } + * **/ +router.put('/:id', vocabController.updateVocab); + +/** + * ============================================ + * DELETE /api/vocabs/:id + * ============================================ + * Xóa mềm vocab (set is_active = false) + * + * INPUT (URL Parameter): + * { + * id: UUID - vocab_id cần xóa + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String + * } + **/ +router.delete('/:id', vocabController.deleteVocab); module.exports = router;