update sentences API
All checks were successful
Deploy to Production / deploy (push) Successful in 22s

This commit is contained in:
silverpro89
2026-02-24 14:29:23 +07:00
parent cfc83c983c
commit d3da098f6f
10 changed files with 1447 additions and 19 deletions

3
app.js
View File

@@ -36,6 +36,7 @@ const gameTypeRoutes = require('./routes/gameTypeRoutes');
const lessonRoutes = require('./routes/lessonRoutes'); const lessonRoutes = require('./routes/lessonRoutes');
const chapterLessonRoutes = require('./routes/chapterLessonRoutes'); const chapterLessonRoutes = require('./routes/chapterLessonRoutes');
const vocabRoutes = require('./routes/vocabRoutes'); const vocabRoutes = require('./routes/vocabRoutes');
const sentencesRoutes = require('./routes/sentencesRoutes');
const grammarRoutes = require('./routes/grammarRoutes'); const grammarRoutes = require('./routes/grammarRoutes');
const storyRoutes = require('./routes/storyRoutes'); const storyRoutes = require('./routes/storyRoutes');
const learningContentRoutes = require('./routes/learningContentRoutes'); const learningContentRoutes = require('./routes/learningContentRoutes');
@@ -167,6 +168,7 @@ app.get('/api', (req, res) => {
games: '/api/games', games: '/api/games',
gameTypes: '/api/game-types', gameTypes: '/api/game-types',
vocabs: '/api/vocabs', vocabs: '/api/vocabs',
sentences: '/api/sentences',
contexts: '/api/contexts', contexts: '/api/contexts',
contextGuides: '/api/context-guides', contextGuides: '/api/context-guides',
upload: '/api/upload', upload: '/api/upload',
@@ -224,6 +226,7 @@ app.use('/api/games', gameRoutes);
app.use('/api/game-types', gameTypeRoutes); app.use('/api/game-types', gameTypeRoutes);
app.use('/api/lessons', lessonRoutes); app.use('/api/lessons', lessonRoutes);
app.use('/api/vocabs', vocabRoutes); app.use('/api/vocabs', vocabRoutes);
app.use('/api/sentences', sentencesRoutes);
app.use('/api/grammar', grammarRoutes); app.use('/api/grammar', grammarRoutes);
app.use('/api/stories', storyRoutes); app.use('/api/stories', storyRoutes);
app.use('/api/learning-content', learningContentRoutes); app.use('/api/learning-content', learningContentRoutes);

View File

@@ -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) * Update context (general update - use with caution)
*/ */

View File

@@ -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
});
}
};

View File

@@ -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();

View File

@@ -35,34 +35,16 @@ const Lesson = sequelize.define('lessons', {
type: DataTypes.TEXT, type: DataTypes.TEXT,
comment: 'Mô tả bài học' 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: { content_json: {
type: DataTypes.JSON, type: DataTypes.JSON,
comment: 'Nội dung học tập dạng JSON: vocabulary, grammar, phonics, review' 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) // Loại nội dung của bài học (để query dễ dàng)
lesson_content_type: { lesson_content_type: {
type: DataTypes.ENUM('vocabulary', 'grammar', 'phonics', 'review', 'mixed'), type: DataTypes.ENUM('vocabulary', 'grammar', 'phonics', 'review', 'mixed'),
allowNull: true, allowNull: true,
comment: 'Loại nội dung: vocabulary, grammar, phonics, review, mixed' 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: { duration_minutes: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
comment: 'Thời lượng (phút)' comment: 'Thời lượng (phút)'
@@ -83,7 +65,7 @@ const Lesson = sequelize.define('lessons', {
comment: 'Thứ tự hiển thị' comment: 'Thứ tự hiển thị'
}, },
thumbnail_url: { thumbnail_url: {
type: DataTypes.STRING(500), type: DataTypes.TEXT,
comment: 'URL ảnh thumbnail' comment: 'URL ảnh thumbnail'
}, },
created_at: { created_at: {

103
models/Sentences.js Normal file
View File

@@ -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;

View File

@@ -36,6 +36,9 @@ const LessonLeaderboard = require('./LessonLeaderboard');
// Group 3.2: Vocabulary System (NEW) // Group 3.2: Vocabulary System (NEW)
const Vocab = require('./Vocab'); const Vocab = require('./Vocab');
// Group 3.2.1: Sentences System (NEW)
const Sentences = require('./Sentences');
// Group 3.3: Grammar System (NEW) // Group 3.3: Grammar System (NEW)
const Grammar = require('./Grammar'); const Grammar = require('./Grammar');
const GrammarMapping = require('./GrammarMapping'); const GrammarMapping = require('./GrammarMapping');
@@ -289,6 +292,9 @@ module.exports = {
// Group 3.2: Vocabulary System (NEW) // Group 3.2: Vocabulary System (NEW)
Vocab, Vocab,
// Group 3.2.1: Sentences System (NEW)
Sentences,
// Group 3.3: Grammar System (NEW) // Group 3.3: Grammar System (NEW)
Grammar, Grammar,

View File

@@ -32,6 +32,10 @@ router.post('/:id/add-images', contextController.addImages);
// Status 4 -> 5: Approve // Status 4 -> 5: Approve
router.post('/:id/approve', contextController.approveContext); 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) // Get all contexts (with optional filters)
router.get('/', contextController.getAllContexts); router.get('/', contextController.getAllContexts);

271
routes/sentencesRoutes.js Normal file
View File

@@ -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;

34
sync-sentences.js Normal file
View File

@@ -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();