From d3da098f6f6c74c62a75306cf94abf17d9826a03 Mon Sep 17 00:00:00 2001 From: silverpro89 Date: Tue, 24 Feb 2026 14:29:23 +0700 Subject: [PATCH] update sentences API --- app.js | 3 + controllers/contextController.js | 91 ++++ controllers/sentencesController.js | 796 +++++++++++++++++++++++++++++ import-context-to-sentences.js | 138 +++++ models/Lesson.js | 20 +- models/Sentences.js | 103 ++++ models/index.js | 6 + routes/contextRoutes.js | 4 + routes/sentencesRoutes.js | 271 ++++++++++ sync-sentences.js | 34 ++ 10 files changed, 1447 insertions(+), 19 deletions(-) create mode 100644 controllers/sentencesController.js create mode 100644 import-context-to-sentences.js create mode 100644 models/Sentences.js create mode 100644 routes/sentencesRoutes.js create mode 100644 sync-sentences.js diff --git a/app.js b/app.js index 913a862..992bc95 100644 --- a/app.js +++ b/app.js @@ -36,6 +36,7 @@ const gameTypeRoutes = require('./routes/gameTypeRoutes'); const lessonRoutes = require('./routes/lessonRoutes'); const chapterLessonRoutes = require('./routes/chapterLessonRoutes'); const vocabRoutes = require('./routes/vocabRoutes'); +const sentencesRoutes = require('./routes/sentencesRoutes'); const grammarRoutes = require('./routes/grammarRoutes'); const storyRoutes = require('./routes/storyRoutes'); const learningContentRoutes = require('./routes/learningContentRoutes'); @@ -167,6 +168,7 @@ app.get('/api', (req, res) => { games: '/api/games', gameTypes: '/api/game-types', vocabs: '/api/vocabs', + sentences: '/api/sentences', contexts: '/api/contexts', contextGuides: '/api/context-guides', upload: '/api/upload', @@ -224,6 +226,7 @@ app.use('/api/games', gameRoutes); app.use('/api/game-types', gameTypeRoutes); app.use('/api/lessons', lessonRoutes); app.use('/api/vocabs', vocabRoutes); +app.use('/api/sentences', sentencesRoutes); 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 90646ac..52c06e1 100644 --- a/controllers/contextController.js +++ b/controllers/contextController.js @@ -387,6 +387,97 @@ class ContextController { } } + /** + * Search contexts with partial match on title/context + filter by type_image + * + * POST /api/contexts/search + * Body: + * { + * search : String - tìm kiếm một phần trong title HOẶC context (OR) + * title : String - tìm riêng trong title (LIKE) + * context_text : String - tìm riêng trong context (LIKE) + * type_image : String - exact match (e.g., 'small', 'square', 'normal') + * type : String - exact match + * status : Number - exact match + * grade : Number - exact match + * page : Number (default: 1) + * limit : Number (default: 50) + * } + */ + async searchContexts(req, res, next) { + try { + const { + search, + title, + context_text, + type_image, + type, + status, + grade, + page = 1, + limit = 50 + } = req.body; + + const { Op } = require('sequelize'); + const offset = (page - 1) * limit; + const where = {}; + + // ── Exact-match filters ────────────────────────────────────────────── + if (type_image !== undefined && type_image !== null && type_image !== '') { + where.type_image = type_image; + } + if (type) where.type = type; + if (status !== undefined && status !== null) where.status = parseInt(status); + if (grade !== undefined && grade !== null) where.grade = parseInt(grade); + + // ── Text search ────────────────────────────────────────────────────── + // `search` → title OR context (cả hai cùng lúc) + // `title` → chỉ title + // `context_text` → chỉ context + const textConditions = []; + + if (search) { + textConditions.push( + { title: { [Op.like]: `%${search}%` } }, + { context: { [Op.like]: `%${search}%` } } + ); + } + if (title) { + textConditions.push({ title: { [Op.like]: `%${title}%` } }); + } + if (context_text) { + textConditions.push({ context: { [Op.like]: `%${context_text}%` } }); + } + + if (textConditions.length > 0) { + where[Op.or] = textConditions; + } + + const { count, rows } = await Context.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['created_at', 'DESC']] + }); + + res.json({ + success: true, + message: 'Search completed successfully', + data: { + contexts: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit) + } + } + }); + } catch (error) { + next(error); + } + } + /** * Update context (general update - use with caution) */ diff --git a/controllers/sentencesController.js b/controllers/sentencesController.js new file mode 100644 index 0000000..6338569 --- /dev/null +++ b/controllers/sentencesController.js @@ -0,0 +1,796 @@ +const { Sentences } = require('../models'); +const { Op } = require('sequelize'); + +/** + * ============================================ + * CREATE SENTENCE + * ============================================ + * Tạo mới một sentence entry + * + * @route POST /api/sentences + * @access Private (requires authentication) + * + * INPUT: + * { + * text: String (required) - nội dung câu + * ipa: String - phiên âm IPA + * vi: String - nghĩa tiếng Việt + * category: String - category của câu (e.g., Action Verbs, Nouns) + * topic: String - chủ đề (e.g., Food, Travel, Education) + * 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 + * tags: JSON Array - các tags để phân loại + * usage_note: String - lưu ý về ngữ cảnh sử dụng + * etc: String - các thông tin khác + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Sentence object đã tạo + * } + */ +exports.createSentence = async (req, res) => { + try { + const { + text, + ipa, + vi, + category, + topic, + image_small, + image_square, + image_normal, + audio, + tags, + usage_note, + etc + } = req.body; + + // Validate required fields + if (!text) { + return res.status(400).json({ + success: false, + message: 'text is a required field' + }); + } + + const sentence = await Sentences.create({ + text, + ipa: ipa || null, + vi: vi || '', + category: category || null, + topic: topic || null, + image_small: image_small || [], + image_square: image_square || [], + image_normal: image_normal || [], + audio: audio || null, + tags: tags || [], + usage_note: usage_note || '', + etc: etc || '', + is_active: true + }); + + res.status(201).json({ + success: true, + message: 'Sentence created successfully', + data: sentence + }); + + } catch (error) { + console.error('Error creating sentence:', error); + res.status(500).json({ + success: false, + message: 'Error creating sentence', + error: error.message + }); + } +}; + +/** + * ============================================ + * GET ALL SENTENCES + * ============================================ + * Lấy danh sách sentences với phân trang và filter + * + * @route GET /api/sentences + * @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 + * text: String - lọc theo text chính xác + * search: String - tìm kiếm trong text 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 Sentence objects, + * pagination: { total, page, limit, totalPages } + * } + */ +exports.getAllSentences = async (req, res) => { + try { + const { + page = 1, + limit = 20, + category, + topic, + text, + search, + is_active = true + } = req.query; + + const offset = (page - 1) * limit; + const where = {}; + + if (is_active !== undefined) { + where.is_active = is_active === 'true' || is_active === true; + } + + if (category) { + where.category = category; + } + + if (topic) { + where.topic = topic; + } + + if (text) { + where.text = text; + } + + if (search) { + where[Op.or] = [ + { text: { [Op.like]: `%${search}%` } }, + { vi: { [Op.like]: `%${search}%` } } + ]; + } + + const { count, rows } = await Sentences.findAndCountAll({ + where, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['created_at', 'DESC']] + }); + + res.json({ + success: true, + message: 'Sentences retrieved successfully', + data: rows, + pagination: { + total: count, + page: parseInt(page), + limit: parseInt(limit), + totalPages: Math.ceil(count / limit) + } + }); + + } catch (error) { + console.error('Error getting sentences:', error); + res.status(500).json({ + success: false, + message: 'Error retrieving sentences', + error: error.message + }); + } +}; + +/** + * ============================================ + * GET SENTENCE BY ID + * ============================================ + * Lấy chi tiết một sentence theo id + * + * @route GET /api/sentences/:id + * @access Private + * + * INPUT (URL Parameter): + * { + * id: UUID - id của sentence cần lấy + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Sentence object với đầy đủ thông tin + * } + */ +exports.getSentenceById = async (req, res) => { + try { + const { id } = req.params; + + const sentence = await Sentences.findOne({ + where: { + id, + is_active: true + } + }); + + if (!sentence) { + return res.status(404).json({ + success: false, + message: 'Sentence not found' + }); + } + + res.json({ + success: true, + message: 'Sentence retrieved successfully', + data: sentence + }); + + } catch (error) { + console.error('Error getting sentence:', error); + res.status(500).json({ + success: false, + message: 'Error retrieving sentence', + error: error.message + }); + } +}; + +/** + * ============================================ + * GET SENTENCES WITHOUT IPA + * ============================================ + * Lấy tất cả các sentences chưa có IPA + * + * @route GET /api/sentences/missing/ipa + * @access Private + * + * INPUT (Query Parameters): + * { + * page: Number (mặc định: 1) + * limit: Number (mặc định: 50) + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of Sentence objects, + * pagination: { total, page, limit, totalPages } + * } + */ +exports.getSentencesWithoutIpa = async (req, res) => { + try { + const { page = 1, limit = 50 } = req.query; + const offset = (page - 1) * limit; + + const { count, rows } = await Sentences.findAndCountAll({ + where: { + is_active: true, + [Op.or]: [ + { ipa: null }, + { ipa: '' } + ] + }, + limit: parseInt(limit), + offset: parseInt(offset), + order: [['text', 'ASC']] + }); + + res.json({ + success: true, + message: 'Sentences 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 sentences without IPA:', error); + res.status(500).json({ + success: false, + message: 'Error retrieving sentences without IPA', + error: error.message + }); + } +}; + +/** + * ============================================ + * GET SENTENCES WITHOUT IMAGES + * ============================================ + * Lấy tất cả các sentences chưa đủ hình ảnh + * + * @route GET /api/sentences/missing/images + * @access Private + * + * INPUT (Query Parameters): + * { + * page: Number (mặc định: 1) + * limit: Number (mặc định: 50) + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of Sentence objects, + * pagination: { total, page, limit, totalPages } + * } + */ +exports.getSentencesWithoutImages = async (req, res) => { + try { + const { page = 1, limit = 50 } = req.query; + const offset = (page - 1) * limit; + + const { count, rows } = await Sentences.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: 'Sentences 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 sentences without images:', error); + res.status(500).json({ + success: false, + message: 'Error retrieving sentences without images', + error: error.message + }); + } +}; + +/** + * ============================================ + * UPDATE SENTENCE + * ============================================ + * Cập nhật thông tin sentence + * + * @route PUT /api/sentences/:id + * @access Private + * + * INPUT (URL Parameter + Body): + * { + * id: UUID - id cần update + * Body: Object - các trường cần update + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Updated Sentence object + * } + */ +exports.updateSentence = async (req, res) => { + try { + const { id } = req.params; + const updateData = req.body; + + const sentence = await Sentences.findOne({ + where: { id } + }); + + if (!sentence) { + return res.status(404).json({ + success: false, + message: 'Sentence not found' + }); + } + + await sentence.update(updateData); + + res.json({ + success: true, + message: 'Sentence updated successfully', + data: sentence + }); + + } catch (error) { + console.error('Error updating sentence:', error); + res.status(500).json({ + success: false, + message: 'Error updating sentence', + error: error.message + }); + } +}; + +/** + * ============================================ + * DELETE SENTENCE (SOFT DELETE) + * ============================================ + * Xóa mềm sentence (set is_active = false) + * + * @route DELETE /api/sentences/:id + * @access Private + * + * INPUT (URL Parameter): + * { + * id: UUID - id cần xóa + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String + * } + */ +exports.deleteSentence = async (req, res) => { + try { + const { id } = req.params; + + const sentence = await Sentences.findOne({ + where: { id } + }); + + if (!sentence) { + return res.status(404).json({ + success: false, + message: 'Sentence not found' + }); + } + + await sentence.update({ is_active: false }); + + res.json({ + success: true, + message: 'Sentence deleted successfully' + }); + + } catch (error) { + console.error('Error deleting sentence:', error); + res.status(500).json({ + success: false, + message: 'Error deleting sentence', + error: error.message + }); + } +}; + +/** + * ============================================ + * BULK CREATE SENTENCES + * ============================================ + * Tạo nhiều sentences cùng lúc + * + * @route POST /api/sentences/bulk + * @access Private + * + * INPUT (Body): + * { + * sentences: Array of Sentence objects - mỗi object phải có text + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of created Sentence objects, + * count: Number - số lượng đã tạo + * } + */ +exports.bulkCreateSentences = async (req, res) => { + try { + const { sentences } = req.body; + + if (!Array.isArray(sentences) || sentences.length === 0) { + return res.status(400).json({ + success: false, + message: 'sentences must be a non-empty array' + }); + } + + // Validate each sentence has required fields + for (let i = 0; i < sentences.length; i++) { + if (!sentences[i].text) { + return res.status(400).json({ + success: false, + message: `Sentence at index ${i} is missing required field (text)` + }); + } + } + + const createdSentences = await Sentences.bulkCreate(sentences); + + res.status(201).json({ + success: true, + message: `${createdSentences.length} sentences created successfully`, + data: createdSentences, + count: createdSentences.length + }); + + } catch (error) { + console.error('Error bulk creating sentences:', error); + res.status(500).json({ + success: false, + message: 'Error creating sentences', + error: error.message + }); + } +}; + +/** + * ============================================ + * GET SENTENCE STATISTICS + * ============================================ + * Lấy thống kê về sentences + * + * @route GET /api/sentences/stats/overview + * @access Private + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: { + * total: { active, inactive, all }, + * by_category: Array [{category, count}], + * by_topic: Array [{topic, count}] + * } + * } + */ +exports.getSentenceStats = async (req, res) => { + try { + const { sequelize } = Sentences; + + const totalActive = await Sentences.count({ where: { is_active: true } }); + const totalInactive = await Sentences.count({ where: { is_active: false } }); + + const byCategory = await Sentences.findAll({ + where: { is_active: true }, + attributes: [ + 'category', + [sequelize.fn('COUNT', sequelize.col('id')), 'count'] + ], + group: ['category'], + order: [[sequelize.fn('COUNT', sequelize.col('id')), 'DESC']], + raw: true + }); + + const byTopic = await Sentences.findAll({ + where: { is_active: true }, + attributes: [ + 'topic', + [sequelize.fn('COUNT', sequelize.col('id')), 'count'] + ], + group: ['topic'], + order: [[sequelize.fn('COUNT', sequelize.col('id')), 'DESC']], + raw: true + }); + + res.json({ + success: true, + message: 'Statistics retrieved successfully', + data: { + total: { + active: totalActive, + inactive: totalInactive, + all: totalActive + totalInactive + }, + by_category: byCategory, + by_topic: byTopic + } + }); + + } catch (error) { + console.error('Error getting sentence stats:', error); + res.status(500).json({ + success: false, + message: 'Error retrieving statistics', + error: error.message + }); + } +}; + +/** + * ============================================ + * SEARCH SENTENCES + * ============================================ + * Tìm kiếm sentences nâng cao với nhiều filter + * + * @route POST /api/sentences/search + * @access Private + * + * INPUT (Body): + * { + * topic: String (optional) + * category: String (optional) + * text: String (optional) - partial match + * vi: String (optional) - partial match + * tags: Array (optional) - lọc theo tags + * page: Number (mặc định: 1) + * limit: Number (mặc định: 100) + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of Sentence objects, + * pagination: { total, page, limit, totalPages } + * } + */ +exports.searchSentences = async (req, res) => { + try { + const { + topic, + category, + text, + vi, + page = 1, + limit = 100 + } = req.body; + + const offset = (page - 1) * limit; + const where = { is_active: true }; + + if (topic) { + where.topic = topic; + } + + if (category) { + where.category = category; + } + + if (text) { + where.text = { [Op.like]: `%${text}%` }; + } + + if (vi) { + where.vi = { [Op.like]: `%${vi}%` }; + } + + const { count, rows } = await Sentences.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 sentences:', error); + res.status(500).json({ + success: false, + message: 'Error searching sentences', + error: error.message + }); + } +}; + +/** + * ============================================ + * GET ALL CATEGORIES + * ============================================ + * Lấy danh sách tất cả categories có trong database + * + * @route GET /api/sentences/meta/categories + * @access Private + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of String, + * count: Number + * } + */ +exports.getAllCategories = async (req, res) => { + try { + const categories = await Sentences.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/sentences/meta/topics + * @access Private + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of String, + * count: Number + * } + */ +exports.getAllTopics = async (req, res) => { + try { + const topics = await Sentences.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 + }); + } +}; diff --git a/import-context-to-sentences.js b/import-context-to-sentences.js new file mode 100644 index 0000000..ecf7837 --- /dev/null +++ b/import-context-to-sentences.js @@ -0,0 +1,138 @@ +/** + * Import Context (status=5) → Sentences + * + * Logic: + * 1. Lấy tất cả Context có status = 5 + * 2. So sánh Context.title với Sentences.text + * - Nếu chưa có → tạo mới Sentence + * - Nếu đã có → cập nhật image vào đúng mảng + * 3. Xác định slot image dựa vào Context.image.split("_")[1]: + * - "square" → image_square + * - "small" → image_small + * - "normal" → image_normal + * 4. Chuyển Context.status → 6 + */ + +const { sequelize } = require('./config/database'); +const Context = require('./models/Context'); +const Sentences = require('./models/Sentences'); + +// ─── helper: thêm URL vào mảng JSON (không trùng lặp) ─────────────────────── +function addToArray(existing, url) { + const arr = Array.isArray(existing) ? [...existing] : []; + if (!arr.includes(url)) arr.push(url); + return arr; +} + +// ─── helper: xác định slot image từ tên file/URL ──────────────────────────── +function resolveImageSlot(imageUrl) { + if (!imageUrl) return null; + + // Lấy phần tên file (bỏ path/query) + const filename = imageUrl.split('/').pop().split('?')[0]; + const parts = filename.split('_'); + + // Duyệt tất cả phần tử, tìm keyword + for (const part of parts) { + const key = part.toLowerCase(); + if (key === 'square') return 'image_square'; + if (key === 'small') return 'image_small'; + if (key === 'normal') return 'image_normal'; + } + + return null; // không xác định được +} + +// ─── main ──────────────────────────────────────────────────────────────────── +async function run() { + try { + await sequelize.authenticate(); + console.log('✅ Database connected\n'); + + // 1. Lấy tất cả Context có status = 5 + const contexts = await Context.findAll({ + where: { status: 5 } + }); + + console.log(`📦 Tìm thấy ${contexts.length} Context(s) có status = 5\n`); + + if (contexts.length === 0) { + console.log('Không có gì để xử lý.'); + process.exit(0); + } + + let created = 0; + let updated = 0; + let skipped = 0; + + for (const ctx of contexts) { + const text = ctx.context; // so sánh với Sentences.text + const imageUrl = ctx.image; + const imageSlot = resolveImageSlot(imageUrl); + + if (!text) { + console.warn(` ⚠️ Context [${ctx.uuid}] không có context — bỏ qua`); + skipped++; + continue; + } + + // 2. Tìm Sentence có text khớp + let sentence = await Sentences.findOne({ + where: { text } + }); + + if (!sentence) { + // ── Tạo mới ────────────────────────────────────────────────────────── + const newData = { + text, + is_active: true, + image_small: [], + image_square: [], + image_normal: [], + }; + + // Gán image vào đúng slot + if (imageUrl && imageSlot) { + newData[imageSlot] = [imageUrl]; + } else if (imageUrl) { + console.warn(` ⚠️ Không xác định slot image từ URL: "${imageUrl}" — bỏ qua image`); + } + + await Sentences.create(newData); + console.log(` ✅ [TẠO MỚI] "${text}"${imageSlot ? ` → ${imageSlot}` : ''}`); + created++; + + } else { + // ── Cập nhật image ─────────────────────────────────────────────────── + if (imageUrl && imageSlot) { + const updatedArr = addToArray(sentence[imageSlot], imageUrl); + await sentence.update({ [imageSlot]: updatedArr }); + console.log(` 🔄 [CẬP NHẬT] "${text}" → ${imageSlot} (+1 ảnh)`); + updated++; + } else { + console.warn(` ⚠️ [BỎ QUA IMAGE] "${text}" — URL trống hoặc không xác định slot`); + skipped++; + } + } + + // 3. Chuyển Context.status → 6 + await ctx.update({ status: 6 }); + } + + console.log('\n─────────────────────────────────────'); + console.log(`📊 Kết quả:`); + console.log(` ✅ Tạo mới : ${created}`); + console.log(` 🔄 Cập nhật : ${updated}`); + console.log(` ⚠️ Bỏ qua : ${skipped}`); + console.log(` 📌 Tổng : ${contexts.length}`); + console.log('─────────────────────────────────────'); + + process.exit(0); + } catch (error) { + console.error('❌ Lỗi:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +run(); diff --git a/models/Lesson.js b/models/Lesson.js index 919424f..6331a0b 100644 --- a/models/Lesson.js +++ b/models/Lesson.js @@ -35,34 +35,16 @@ const Lesson = sequelize.define('lessons', { type: DataTypes.TEXT, comment: 'Mô tả bài học' }, - - // Dạng 1: JSON Content - Nội dung học tập dạng JSON - // Cấu trúc content_json phụ thuộc vào lesson_content_type: - // - vocabulary: { type: "vocabulary", vocabulary_ids: [uuid1, uuid2, ...], exercises: [...] } - // - grammar: { type: "grammar", grammar_ids: [uuid1, uuid2, ...], examples: [...], exercises: [...] } - // - phonics: { type: "phonics", phonics_rules: [{ipa: "/æ/", words: [...]}], exercises: [...] } - // - review: { type: "review", sections: [{type: "vocabulary", ...}, {type: "grammar", ...}, {type: "phonics", ...}] } content_json: { type: DataTypes.JSON, comment: 'Nội dung học tập dạng JSON: vocabulary, grammar, phonics, review' }, - // Loại nội dung của bài học (để query dễ dàng) lesson_content_type: { type: DataTypes.ENUM('vocabulary', 'grammar', 'phonics', 'review', 'mixed'), allowNull: true, comment: 'Loại nội dung: vocabulary, grammar, phonics, review, mixed' }, - - // Dạng 2: URL Content - Chứa link external - content_url: { - type: DataTypes.STRING(500), - comment: 'URL nội dung: video, audio, document, external link' - }, - content_type: { - type: DataTypes.STRING(50), - comment: 'Loại content cho URL: video, audio, pdf, external_link, youtube, etc.' - }, duration_minutes: { type: DataTypes.INTEGER, comment: 'Thời lượng (phút)' @@ -83,7 +65,7 @@ const Lesson = sequelize.define('lessons', { comment: 'Thứ tự hiển thị' }, thumbnail_url: { - type: DataTypes.STRING(500), + type: DataTypes.TEXT, comment: 'URL ảnh thumbnail' }, created_at: { diff --git a/models/Sentences.js b/models/Sentences.js new file mode 100644 index 0000000..2874fea --- /dev/null +++ b/models/Sentences.js @@ -0,0 +1,103 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../config/database'); + +const Sentences = sequelize.define('Sentences', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + comment: 'Unique identifier for sentence entry' + }, + // Từ thực tế (wash, washes, washing, ate, eaten...) + text: { + type: DataTypes.TEXT, + allowNull: false + }, + ipa : { + type: DataTypes.TEXT, + comment: 'International Phonetic Alphabet representation' + }, + // Nội dung dùng chung (có thể lưu JSON để dễ quản lý hoặc dùng chung cho cả group) + vi: { + type: DataTypes.TEXT, + defaultValue: '', + comment: 'Vietnamese meaning' + }, + grade : { + type: DataTypes.TEXT, + defaultValue: '0', + comment: 'Grade level (e.g., Grade 1, Grade 2)' + }, + category: { + type: DataTypes.TEXT, + comment: 'Category of the sentence (e.g., Action Verbs, Nouns)' + }, + topic: { + type: DataTypes.TEXT, + comment: 'Topic of the sentence (e.g., Food, Travel, Education)' + }, + image_small: { + type: DataTypes.JSON, + defaultValue: [], + comment: 'Array of image URLs' + }, + image_square: { + type: DataTypes.JSON, + defaultValue: [], + comment: 'Array of image URLs' + }, + image_normal: { + type: DataTypes.JSON, + defaultValue: [], + comment: 'Array of image URLs' + }, + audio : { + type: DataTypes.JSON, + comment: 'Array of audio URLs' + }, + tags: { + type: DataTypes.JSON, + defaultValue: [], + comment: 'Array of tags for categorization' + }, + usage_note: { + type: DataTypes.TEXT, + defaultValue: '', + comment: 'Lưu ý về ngữ cảnh sử dụng câu này' + }, + etc : { + type: DataTypes.TEXT, + defaultValue: '', + comment: 'Các thông tin khác liên quan đến câu này (ví dụ: level, grammar points, etc.)' + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'Whether this sentence entry is active' + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + } + }, { + tableName: 'sentences', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + name: 'idx_sentences_text', + fields: [{ name: 'text', length: 191 }] + }, + { + name: 'idx_category', + fields: [{ name: 'category', length: 191 }] + } + ] + }); + +module.exports = Sentences; diff --git a/models/index.js b/models/index.js index fd719cc..b390b2a 100644 --- a/models/index.js +++ b/models/index.js @@ -36,6 +36,9 @@ const LessonLeaderboard = require('./LessonLeaderboard'); // Group 3.2: Vocabulary System (NEW) const Vocab = require('./Vocab'); +// Group 3.2.1: Sentences System (NEW) +const Sentences = require('./Sentences'); + // Group 3.3: Grammar System (NEW) const Grammar = require('./Grammar'); const GrammarMapping = require('./GrammarMapping'); @@ -289,6 +292,9 @@ module.exports = { // Group 3.2: Vocabulary System (NEW) Vocab, + + // Group 3.2.1: Sentences System (NEW) + Sentences, // Group 3.3: Grammar System (NEW) Grammar, diff --git a/routes/contextRoutes.js b/routes/contextRoutes.js index fb5a8ef..7dc36cf 100644 --- a/routes/contextRoutes.js +++ b/routes/contextRoutes.js @@ -32,6 +32,10 @@ router.post('/:id/add-images', contextController.addImages); // Status 4 -> 5: Approve router.post('/:id/approve', contextController.approveContext); +// Search contexts: partial match on title/context + filter by type_image, status, etc. +// Body: { search, title, context_text, type_image, type, status, grade, page, limit } +router.post('/search', contextController.searchContexts); + // Get all contexts (with optional filters) router.get('/', contextController.getAllContexts); diff --git a/routes/sentencesRoutes.js b/routes/sentencesRoutes.js new file mode 100644 index 0000000..08acfa5 --- /dev/null +++ b/routes/sentencesRoutes.js @@ -0,0 +1,271 @@ +const express = require('express'); +const router = express.Router(); +const sentencesController = require('../controllers/sentencesController'); +const { authenticateToken } = require('../middleware/auth'); + +/** + * ============================================ + * POST /api/sentences + * ============================================ + * Tạo một sentence entry mới + * + * INPUT: + * { + * text: String (required) - nội dung câu + * ipa: String - phiên âm IPA + * vi: String - nghĩa tiếng Việt + * category: String - category của câu + * topic: String - chủ đề + * image_small: JSON Array - mảng URLs hình ảnh nhỏ + * image_square: JSON Array - mảng URLs hình ảnh vuông + * image_normal: JSON Array - mảng URLs hình ảnh bình thường + * audio: JSON Array - mảng URLs audio files + * tags: JSON Array - các tags phân loại + * usage_note: String - lưu ý ngữ cảnh sử dụng + * etc: String - các thông tin khác + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Sentence object đã tạo (bao gồm id, created_at, updated_at) + * } + **/ +router.post('/', sentencesController.createSentence); + +/** + * ============================================ + * POST /api/sentences/bulk + * ============================================ + * Tạo nhiều sentence entries cùng lúc + * + * INPUT: + * { + * sentences: Array of Sentence objects - mỗi object phải có text + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of created Sentence objects, + * count: Number - số lượng đã tạo + * } + **/ +router.post('/bulk', sentencesController.bulkCreateSentences); + +/** + * ============================================ + * POST /api/sentences/search + * ============================================ + * Tìm kiếm sentence nâng cao với nhiều filter + * + * INPUT: + * { + * topic: String (optional) + * category: String (optional) + * text: String (optional) - partial match + * vi: String (optional) - partial match + * page: Number (mặc định: 1) + * limit: Number (mặc định: 100) + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of Sentence objects, + * pagination: { total, page, limit, totalPages } + * } + **/ +router.post('/search', sentencesController.searchSentences); + +/** + * ============================================ + * GET /api/sentences + * ============================================ + * Lấy danh sách tất cả sentences với phân trang và filter + * + * INPUT (Query Parameters): + * { + * page: Number (mặc định: 1) + * limit: Number (mặc định: 20) + * category: String + * topic: String + * text: String + * search: String - tìm kiếm trong text và vi + * is_active: Boolean (mặc định: true) + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of Sentence objects, + * pagination: { total, page, limit, totalPages } + * } + **/ +router.get('/', sentencesController.getAllSentences); + +/** + * ============================================ + * GET /api/sentences/stats/overview + * ============================================ + * Lấy thống kê tổng quan về sentences + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: { + * total: { active, inactive, all }, + * by_category: Array [{category, count}], + * by_topic: Array [{topic, count}] + * } + * } + **/ +router.get('/stats/overview', sentencesController.getSentenceStats); + +/** + * ============================================ + * GET /api/sentences/meta/categories + * ============================================ + * Lấy danh sách tất cả categories + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of String, + * count: Number + * } + **/ +router.get('/meta/categories', sentencesController.getAllCategories); + +/** + * ============================================ + * GET /api/sentences/meta/topics + * ============================================ + * Lấy danh sách tất cả topics + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of String, + * count: Number + * } + **/ +router.get('/meta/topics', sentencesController.getAllTopics); + +/** + * ============================================ + * GET /api/sentences/missing/ipa + * ============================================ + * Lấy tất cả sentences chưa có IPA + * + * INPUT (Query Parameters): + * { + * page: Number (mặc định: 1) + * limit: Number (mặc định: 50) + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of Sentence objects, + * pagination: { total, page, limit, totalPages } + * } + **/ +router.get('/missing/ipa', sentencesController.getSentencesWithoutIpa); + +/** + * ============================================ + * GET /api/sentences/missing/images + * ============================================ + * Lấy tất cả sentences chưa đủ hình ảnh + * + * INPUT (Query Parameters): + * { + * page: Number (mặc định: 1) + * limit: Number (mặc định: 50) + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Array of Sentence objects, + * pagination: { total, page, limit, totalPages } + * } + **/ +router.get('/missing/images', sentencesController.getSentencesWithoutImages); + +/** + * ============================================ + * GET /api/sentences/:id + * ============================================ + * Lấy chi tiết một sentence theo ID + * + * INPUT (URL Parameter): + * { + * id: UUID - id của sentence cần lấy + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Sentence object + * } + **/ +router.get('/:id', sentencesController.getSentenceById); + +/** + * ============================================ + * PUT /api/sentences/:id + * ============================================ + * Cập nhật thông tin sentence + * + * INPUT (URL Parameter + Body): + * { + * id: UUID - id cần update + * Body: Object - các trường cần update + * { + * text, ipa, vi, category, topic, + * image_small, image_square, image_normal, + * audio, tags, usage_note, etc, is_active + * } + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String, + * data: Updated Sentence object + * } + **/ +router.put('/:id', sentencesController.updateSentence); + +/** + * ============================================ + * DELETE /api/sentences/:id + * ============================================ + * Xóa mềm sentence (set is_active = false) + * + * INPUT (URL Parameter): + * { + * id: UUID - id cần xóa + * } + * + * OUTPUT: + * { + * success: Boolean, + * message: String + * } + **/ +router.delete('/:id', sentencesController.deleteSentence); + +module.exports = router; diff --git a/sync-sentences.js b/sync-sentences.js new file mode 100644 index 0000000..003a9d0 --- /dev/null +++ b/sync-sentences.js @@ -0,0 +1,34 @@ +/** + * Sync only the Sentences model to database + */ +const { sequelize } = require('./config/database'); +const Sentences = require('./models/Sentences'); + +async function syncSentences() { + try { + console.log('🔄 Syncing Sentences table...'); + await sequelize.authenticate(); + console.log('✅ Database connection OK'); + + await Sentences.sync({ alter: true }); + console.log('✅ Sentences table synced successfully'); + + const [tables] = await sequelize.query("SHOW TABLES LIKE 'sentences'"); + if (tables.length > 0) { + console.log('✅ Table "sentences" confirmed in database'); + } + + const [columns] = await sequelize.query("SHOW COLUMNS FROM sentences"); + console.log('\n📋 Columns in sentences table:'); + columns.forEach(col => { + console.log(` - ${col.Field} (${col.Type})`); + }); + + process.exit(0); + } catch (error) { + console.error('❌ Error syncing Sentences:', error.message); + process.exit(1); + } +} + +syncSentences();