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

This commit is contained in:
vuongps38770
2026-02-28 20:00:38 +07:00
parent f96833a7e4
commit 72283443ab
15 changed files with 972 additions and 318 deletions

View File

@@ -1,4 +1,5 @@
const { Context, Vocab } = require('../models'); const { Context, Vocab } = require('../models');
const { Op } = require('sequelize');
/** /**
* Context Controller - Workflow-based status management * Context Controller - Workflow-based status management
@@ -11,7 +12,7 @@ class ContextController {
*/ */
async createContext(req, res, next) { async createContext(req, res, next) {
try { try {
const { title, desc, grade, type, type_image , reference_id } = req.body; const { title, desc, grade, type, type_image, reference_id } = req.body;
// Validate required fields // Validate required fields
if (!title || !desc || !grade) { if (!title || !desc || !grade) {
@@ -58,18 +59,96 @@ class ContextController {
async getContextsByStatus(req, res, next) { async getContextsByStatus(req, res, next) {
try { try {
const { status } = req.params; const { status } = req.params;
const { page = 1, limit = 50, type, grade } = req.query; const { page = 1, limit = 50, type, grade, title, date, from, to, sort } = req.query;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const where = { status: parseInt(status) }; const where = { status: parseInt(status) };
if (type) where.type = type; if (type) where.type = type;
if (grade) where.grade = parseInt(grade); if (grade) where.grade = parseInt(grade);
if (title) where.title = { [Op.like]: `%${title}%` };
// Hàm helper để chuẩn hóa và parse date cho dải thời gian
const parseDateRange = (dStr, isEndDate = false) => {
if (!dStr) return null;
let s = String(dStr).replace('_', 'T').replace(' ', 'T');
if (s.includes(':') && !s.includes('Z') && !s.match(/[+-]\d{2}:?\d{2}$/)) {
s += 'Z';
}
const d = new Date(s);
if (isNaN(d.getTime())) return null;
if (!s.includes(':') && isEndDate) {
d.setHours(23, 59, 59, 999);
}
return d;
};
if (from || to) {
where.updated_at = {};
if (from) {
const startDate = parseDateRange(from);
if (startDate) where.updated_at[Op.gte] = startDate;
}
if (to) {
const endDate = parseDateRange(to, true);
if (endDate) where.updated_at[Op.lte] = endDate;
}
} else if (date) {
// Chuẩn hóa chuỗi ngày tháng: thay '_' hoặc khoảng cách bằng 'T' để dễ xử lý
let dateString = String(date).replace('_', 'T').replace(' ', 'T');
// Nếu chuỗi có giờ phút (có dấu :) nhưng chưa có múi giờ (Z hoặc +/-)
// Ta mặc định là giờ UTC để khớp chính xác với những gì người dùng thấy trong DB
if (dateString.includes(':') && !dateString.includes('Z') && !dateString.match(/[+-]\d{2}:?\d{2}$/)) {
dateString += 'Z';
}
const searchDate = new Date(dateString);
if (!isNaN(searchDate.getTime())) {
// Kiểm tra xem có dấu ':' (có giờ phút) không
const hasTime = dateString.includes(':');
if (hasTime) {
// Kiểm tra xem có cung cấp đến giây không (vd: 09:08:18 -> 3 phần)
const isSecondsProvided = dateString.split(':').length === 3;
if (isSecondsProvided) {
// Tìm chính xác giây đó (dải 1000ms)
const startTime = Math.floor(searchDate.getTime() / 1000) * 1000;
const startRange = new Date(startTime);
const endRange = new Date(startTime + 1000);
where.updated_at = { [Op.gte]: startRange, [Op.lt]: endRange };
} else {
// Chỉ có giờ:phút, tìm trong cả phút đó
const startTime = Math.floor(searchDate.getTime() / 60000) * 60000;
const startRange = new Date(startTime);
const endRange = new Date(startTime + 60000);
where.updated_at = { [Op.gte]: startRange, [Op.lt]: endRange };
}
} else {
// Nếu chỉ có ngày (vd: 2026-02-28), tìm cả ngày theo giờ server
const startOfDay = new Date(searchDate);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(searchDate);
endOfDay.setHours(23, 59, 59, 999);
where.updated_at = { [Op.gte]: startOfDay, [Op.lte]: endOfDay };
}
}
}
// Sort order
let order = [['title', 'ASC']]; // default alphabet
if (sort === 'created_at') {
order = [['created_at', 'DESC']];
} else if (sort === 'updated_at') {
order = [['updated_at', 'DESC']];
}
const { count, rows } = await Context.findAndCountAll({ const { count, rows } = await Context.findAndCountAll({
where, where,
limit: parseInt(limit), limit: parseInt(limit),
offset: parseInt(offset), offset: parseInt(offset),
order: [['created_at', 'DESC']] order
}); });
res.json({ res.json({
@@ -308,8 +387,8 @@ class ContextController {
const updatedImagesSquare = currentVocab.image_square || []; const updatedImagesSquare = currentVocab.image_square || [];
updatedImagesSquare.push(context.image); updatedImagesSquare.push(context.image);
await currentVocab.update({ image_square: updatedImagesSquare }); await currentVocab.update({ image_square: updatedImagesSquare });
} else if (context.type_image === 'normal') { } else if (context.type_image === 'normal') {
const updatedImagesNormal = currentVocab.image_normal || []; const updatedImagesNormal = currentVocab.image_normal || [];
updatedImagesNormal.push(context.image); updatedImagesNormal.push(context.image);
await currentVocab.update({ image_normal: updatedImagesNormal }); await currentVocab.update({ image_normal: updatedImagesNormal });
} }
@@ -416,11 +495,11 @@ class ContextController {
type, type,
status, status,
grade, grade,
reference_id,
page = 1, page = 1,
limit = 50 limit = 50
} = req.body; } = req.body;
const { Op } = require('sequelize');
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const where = {}; const where = {};
@@ -430,7 +509,8 @@ class ContextController {
} }
if (type) where.type = type; if (type) where.type = type;
if (status !== undefined && status !== null) where.status = parseInt(status); if (status !== undefined && status !== null) where.status = parseInt(status);
if (grade !== undefined && grade !== null) where.grade = parseInt(grade); if (grade !== undefined && grade !== null) where.grade = parseInt(grade);
if (reference_id) where.reference_id = reference_id;
// ── Text search ────────────────────────────────────────────────────── // ── Text search ──────────────────────────────────────────────────────
// `search` → title OR context (cả hai cùng lúc) // `search` → title OR context (cả hai cùng lúc)
@@ -440,7 +520,7 @@ class ContextController {
if (search) { if (search) {
textConditions.push( textConditions.push(
{ title: { [Op.like]: `%${search}%` } }, { title: { [Op.like]: `%${search}%` } },
{ context: { [Op.like]: `%${search}%` } } { context: { [Op.like]: `%${search}%` } }
); );
} }

View File

@@ -127,6 +127,8 @@ exports.getAllSentences = async (req, res) => {
topic, topic,
text, text,
search, search,
status,
sort,
is_active = true is_active = true
} = req.query; } = req.query;
@@ -137,6 +139,11 @@ exports.getAllSentences = async (req, res) => {
where.is_active = is_active === 'true' || is_active === true; where.is_active = is_active === 'true' || is_active === true;
} }
// Status filter (0 = chưa duyệt, 1 = đã duyệt, undefined = all)
if (status !== undefined && status !== '' && status !== 'all') {
where.status = parseInt(status);
}
if (category) { if (category) {
where.category = category; where.category = category;
} }
@@ -156,11 +163,19 @@ exports.getAllSentences = async (req, res) => {
]; ];
} }
// Sort order
let order = [['created_at', 'DESC']]; // default
if (sort === 'updated_at') {
order = [['updated_at', 'DESC']];
} else if (sort === 'alphabet') {
order = [['text', 'ASC']];
}
const { count, rows } = await Sentences.findAndCountAll({ const { count, rows } = await Sentences.findAndCountAll({
where, where,
limit: parseInt(limit), limit: parseInt(limit),
offset: parseInt(offset), offset: parseInt(offset),
order: [['created_at', 'DESC']] order
}); });
res.json({ res.json({

View File

@@ -60,10 +60,10 @@ const { Op } = require('sequelize');
*/ */
exports.createVocab = async (req, res) => { exports.createVocab = async (req, res) => {
try { try {
const { const {
text, text,
ipa, ipa,
base_word, base_word,
vi, vi,
category, category,
topic, topic,
@@ -160,16 +160,18 @@ exports.createVocab = async (req, res) => {
*/ */
exports.getAllVocabs = async (req, res) => { exports.getAllVocabs = async (req, res) => {
try { try {
const { const {
page = 1, page = 1,
limit = 20, limit = 20,
category, category,
topic, topic,
base_word, base_word,
text, text,
search, search,
grade_start, grade_start,
grade_end, grade_end,
status,
sort,
is_active = true is_active = true
} = req.query; } = req.query;
@@ -181,6 +183,11 @@ exports.getAllVocabs = async (req, res) => {
where.is_active = is_active === 'true' || is_active === true; where.is_active = is_active === 'true' || is_active === true;
} }
// Status filter (0 = chưa duyệt, 1 = đã duyệt, undefined = all)
if (status !== undefined && status !== '' && status !== 'all') {
where.status = parseInt(status);
}
if (category) { if (category) {
where.category = category; where.category = category;
} }
@@ -221,11 +228,19 @@ exports.getAllVocabs = async (req, res) => {
where.grade_number = { [Op.lte]: parseInt(grade_end) }; where.grade_number = { [Op.lte]: parseInt(grade_end) };
} }
// Sort order
let order = [['created_at', 'DESC']]; // default
if (sort === 'updated_at') {
order = [['updated_at', 'DESC']];
} else if (sort === 'alphabet') {
order = [['text', 'ASC']];
}
const { count, rows } = await Vocab.findAndCountAll({ const { count, rows } = await Vocab.findAndCountAll({
where, where,
limit: parseInt(limit), limit: parseInt(limit),
offset: parseInt(offset), offset: parseInt(offset),
order: [['created_at', 'DESC']] order
}); });
res.json({ res.json({
@@ -276,9 +291,9 @@ exports.getVocabById = async (req, res) => {
const { id } = req.params; const { id } = req.params;
const vocab = await Vocab.findOne({ const vocab = await Vocab.findOne({
where: { where: {
vocab_id: id, vocab_id: id,
is_active: true is_active: true
} }
}); });
@@ -339,7 +354,7 @@ exports.getVocabsWithoutIpa = async (req, res) => {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const { count, rows } = await Vocab.findAndCountAll({ const { count, rows } = await Vocab.findAndCountAll({
where: { where: {
is_active: true, is_active: true,
[Op.or]: [ [Op.or]: [
{ ipa: null }, { ipa: null },
@@ -407,7 +422,7 @@ exports.getVocabsWithoutImages = async (req, res) => {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const { count, rows } = await Vocab.findAndCountAll({ const { count, rows } = await Vocab.findAndCountAll({
where: { where: {
is_active: true, is_active: true,
[Op.or]: [ [Op.or]: [
{ image_small: null }, { image_small: null },
@@ -798,7 +813,7 @@ exports.getVocabStats = async (req, res) => {
*/ */
exports.searchVocabs = async (req, res) => { exports.searchVocabs = async (req, res) => {
try { try {
const { const {
topic, topic,
category, category,
base_word, base_word,
@@ -876,7 +891,7 @@ exports.searchVocabs = async (req, res) => {
// Handle shuffle_pos: find replacement words based on syntax // Handle shuffle_pos: find replacement words based on syntax
if (shuffle_pos && typeof shuffle_pos === 'object') { if (shuffle_pos && typeof shuffle_pos === 'object') {
const syntaxConditions = []; const syntaxConditions = [];
if (shuffle_pos.is_subject === true) { if (shuffle_pos.is_subject === true) {
syntaxConditions.push({ 'syntax.is_subject': true }); syntaxConditions.push({ 'syntax.is_subject': true });
} }
@@ -911,7 +926,7 @@ exports.searchVocabs = async (req, res) => {
true true
); );
}); });
if (orConditions.length === 1) { if (orConditions.length === 1) {
where[Op.and] = where[Op.and] || []; where[Op.and] = where[Op.and] || [];
where[Op.and].push(orConditions[0]); where[Op.and].push(orConditions[0]);
@@ -975,7 +990,7 @@ exports.searchVocabs = async (req, res) => {
exports.getAllCategories = async (req, res) => { exports.getAllCategories = async (req, res) => {
try { try {
const categories = await Vocab.findAll({ const categories = await Vocab.findAll({
where: { where: {
is_active: true, is_active: true,
category: { [Op.ne]: null } category: { [Op.ne]: null }
}, },
@@ -1026,7 +1041,7 @@ exports.getAllCategories = async (req, res) => {
exports.getAllTopics = async (req, res) => { exports.getAllTopics = async (req, res) => {
try { try {
const topics = await Vocab.findAll({ const topics = await Vocab.findAll({
where: { where: {
is_active: true, is_active: true,
topic: { [Op.ne]: null } topic: { [Op.ne]: null }
}, },

View File

@@ -1,59 +1,26 @@
/**
* 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 { sequelize } = require('./config/database');
const Context = require('./models/Context'); const { Context, Vocab, Sentences } = require('./models');
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 ──────────────────────────── // ─── helper: xác định slot image từ tên file/URL ────────────────────────────
function resolveImageSlot(imageUrl) { function resolveImageSlot(imageUrl) {
if (!imageUrl) return null; if (!imageUrl) return null;
// Lấy phần tên file (bỏ path/query)
const filename = imageUrl.split('/').pop().split('?')[0]; const filename = imageUrl.split('/').pop().split('?')[0];
const parts = filename.split('_'); const parts = filename.split('_');
// Duyệt tất cả phần tử, tìm keyword
for (const part of parts) { for (const part of parts) {
const key = part.toLowerCase(); const key = part.toLowerCase();
if (key === 'square') return 'image_square'; if (key === 'square') return 'image_square';
if (key === 'small') return 'image_small'; if (key === 'small') return 'image_small';
if (key === 'normal') return 'image_normal'; if (key === 'normal') return 'image_normal';
} }
return null;
return null; // không xác định được
} }
// ─── main ────────────────────────────────────────────────────────────────────
async function run() { async function run() {
try { try {
await sequelize.authenticate(); await sequelize.authenticate();
console.log('✅ Database connected\n'); console.log('✅ Database connected\n');
// 1. Lấy tất cả Context có status = 5 const contexts = await Context.findAll({ where: { status: 5 } });
const contexts = await Context.findAll({
where: { status: 5 }
});
console.log(`📦 Tìm thấy ${contexts.length} Context(s) có status = 5\n`); console.log(`📦 Tìm thấy ${contexts.length} Context(s) có status = 5\n`);
if (contexts.length === 0) { if (contexts.length === 0) {
@@ -61,76 +28,78 @@ async function run() {
process.exit(0); process.exit(0);
} }
let created = 0; let updatedVocab = 0;
let updated = 0; let updatedSentences = 0;
let skipped = 0; let skipped = 0;
for (const ctx of contexts) { for (const ctx of contexts) {
const text = ctx.context; // so sánh với Sentences.text const imageUrl = ctx.image;
const imageUrl = ctx.image; const slot = resolveImageSlot(imageUrl);
const imageSlot = resolveImageSlot(imageUrl);
if (!text) { if (!imageUrl || !slot) {
console.warn(` ⚠️ Context [${ctx.uuid}] không có context — bỏ qua`); console.warn(` ⚠️ [BỎ QUA] ID ${ctx.uuid}: Không xác định được ảnh/slot`);
skipped++; skipped++;
continue; continue;
} }
// 2. Tìm Sentence có text khớp let targetEntity = null;
let sentence = await Sentences.findOne({ let entityType = '';
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++;
// Xử lý Vocab
if (ctx.type === 'vocabulary') {
// Ưu tiên tìm bản ghi đang active
targetEntity = await Vocab.findOne({
where: { text: ctx.title, is_active: true }
});
// Nếu không có bản ghi active, mới tìm bản ghi bất kỳ
if (!targetEntity) targetEntity = await Vocab.findOne({ where: { text: ctx.title } });
entityType = 'Vocab';
} else { } else {
// ── Cập nhật image ─────────────────────────────────────────────────── // Xử lý Sentence (ưu tiên active)
if (imageUrl && imageSlot) { targetEntity = await Sentences.findOne({
const updatedArr = addToArray(sentence[imageSlot], imageUrl); where: { text: ctx.context, is_active: true }
await sentence.update({ [imageSlot]: updatedArr }); });
console.log(` 🔄 [CẬP NHẬT] "${text}" → ${imageSlot} (+1 ảnh)`); if (!targetEntity) targetEntity = await Sentences.findOne({ where: { text: ctx.context } });
updated++;
} else { if (!targetEntity) {
console.warn(` ⚠️ [BỎ QUA IMAGE] "${text}" — URL trống hoặc không xác định slot`); targetEntity = await Sentences.findOne({
skipped++; where: { text: ctx.title, is_active: true }
});
if (!targetEntity) targetEntity = await Sentences.findOne({ where: { text: ctx.title } });
} }
entityType = 'Sentence';
} }
// 3. Chuyển Context.status → 6 if (targetEntity) {
await ctx.update({ status: 6 }); // 1. Cập nhật ảnh (Ghi đè - để mảng chỉ có 1 ảnh)
await targetEntity.update({ [slot]: [imageUrl] });
// 2. Kiểm tra và sửa reference_id nếu lệch
const correctId = targetEntity.vocab_id || targetEntity.id;
const updates = { status: 6 };
if (ctx.reference_id !== correctId) {
updates.reference_id = correctId;
}
await ctx.update(updates);
if (entityType === 'Vocab') updatedVocab++; else updatedSentences++;
console.log(` ✅ [${entityType}] "${ctx.title}" -> ${slot} (Đã ghi đè)`);
} else {
console.warn(` ❌ [KHÔNG THẤY] "${ctx.title}" (Type: ${ctx.type})`);
skipped++;
}
} }
console.log('\n─────────────────────────────────────'); console.log('\n─────────────────────────────────────');
console.log(`📊 Kết quả:`); console.log(`📊 Kết quả:`);
console.log(`Tạo mới : ${created}`); console.log(`Cập nhật Vocab : ${updatedVocab}`);
console.log(` 🔄 Cập nhật : ${updated}`); console.log(` Cập nhật Sentences: ${updatedSentences}`);
console.log(` ⚠️ Bỏ qua : ${skipped}`); console.log(` ⚠️ Bỏ qua : ${skipped}`);
console.log(` 📌 Tổng : ${contexts.length}`);
console.log('─────────────────────────────────────'); console.log('─────────────────────────────────────');
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error('❌ Lỗi:', error.message); console.error('❌ Lỗi:', error.message);
console.error(error.stack);
process.exit(1); process.exit(1);
} }
} }

View File

@@ -0,0 +1,104 @@
const { sequelize } = require('../config/database');
/**
* Migration: Thêm trường `status` (INT, default 0) và `etc` (TEXT, default '')
* cho cả bảng `vocab` và `sentences`.
*
* - status: dùng để confirm lại vocab/sentence (0 = chưa confirm, etc.)
* - etc: thông tin bổ sung dạng chuỗi
*/
async function run() {
try {
await sequelize.authenticate();
console.log('✅ Database connected\n');
const queryInterface = sequelize.getQueryInterface();
// ─── VOCAB ───────────────────────────────────────────
console.log('📦 Đang migrate bảng VOCAB...');
// Kiểm tra & thêm status
try {
await queryInterface.addColumn('vocab', 'status', {
type: sequelize.Sequelize.DataTypes.INTEGER,
defaultValue: 0,
allowNull: false,
comment: 'Trạng thái confirm (0 = chưa confirm)'
});
console.log(' ✅ Đã thêm cột "status" vào bảng vocab');
} catch (e) {
if (e.message.includes('Duplicate') || e.message.includes('already exists') || e.original?.code === 'ER_DUP_FIELDNAME') {
console.log(' ⚠️ Cột "status" đã tồn tại trong bảng vocab, bỏ qua.');
} else {
throw e;
}
}
// Kiểm tra & thêm etc
try {
await queryInterface.addColumn('vocab', 'etc', {
type: sequelize.Sequelize.DataTypes.TEXT,
defaultValue: '',
allowNull: true,
comment: 'Thông tin bổ sung'
});
console.log(' ✅ Đã thêm cột "etc" vào bảng vocab');
} catch (e) {
if (e.message.includes('Duplicate') || e.message.includes('already exists') || e.original?.code === 'ER_DUP_FIELDNAME') {
console.log(' ⚠️ Cột "etc" đã tồn tại trong bảng vocab, bỏ qua.');
} else {
throw e;
}
}
// ─── SENTENCES ───────────────────────────────────────
console.log('\n📦 Đang migrate bảng SENTENCES...');
// Kiểm tra & thêm status
try {
await queryInterface.addColumn('sentences', 'status', {
type: sequelize.Sequelize.DataTypes.INTEGER,
defaultValue: 0,
allowNull: false,
comment: 'Trạng thái confirm (0 = chưa confirm)'
});
console.log(' ✅ Đã thêm cột "status" vào bảng sentences');
} catch (e) {
if (e.message.includes('Duplicate') || e.message.includes('already exists') || e.original?.code === 'ER_DUP_FIELDNAME') {
console.log(' ⚠️ Cột "status" đã tồn tại trong bảng sentences, bỏ qua.');
} else {
throw e;
}
}
// Kiểm tra & thêm etc (sentences đã có etc trong model nhưng chưa chắc có trong DB)
try {
await queryInterface.addColumn('sentences', 'etc', {
type: sequelize.Sequelize.DataTypes.TEXT,
defaultValue: '',
allowNull: true,
comment: 'Thông tin bổ sung'
});
console.log(' ✅ Đã thêm cột "etc" vào bảng sentences');
} catch (e) {
if (e.message.includes('Duplicate') || e.message.includes('already exists') || e.original?.code === 'ER_DUP_FIELDNAME') {
console.log(' ⚠️ Cột "etc" đã tồn tại trong bảng sentences, bỏ qua.');
} else {
throw e;
}
}
console.log('\n─────────────────────────────────────');
console.log('✅ Migration hoàn tất!');
console.log(' - vocab: status (INT, default 0), etc (TEXT, default "")');
console.log(' - sentences: status (INT, default 0), etc (TEXT, default "")');
console.log('─────────────────────────────────────');
process.exit(0);
} catch (error) {
console.error('❌ Lỗi:', error.message);
process.exit(1);
}
}
run();

View File

@@ -0,0 +1,53 @@
const { Context } = require('../models');
const { Op } = require('sequelize');
const { sequelize } = require('../config/database');
async function checkNullRefId() {
try {
await sequelize.authenticate();
console.log('✅ Kết nối database thành công.');
// Kiểm tra tất cả các bản ghi có reference_id là null hoặc chuỗi rỗng
const nullRefCount = await Context.count({
where: {
[Op.or]: [
{ reference_id: null },
{ reference_id: '' }
]
}
});
const totalCount = await Context.count();
// Thêm thống kê theo status để dễ hình dung
const statsByStatus = await Context.findAll({
attributes: ['status', [sequelize.fn('COUNT', sequelize.col('uuid')), 'count']],
where: {
[Op.or]: [
{ reference_id: null },
{ reference_id: '' }
]
},
group: ['status']
});
console.log('\n--- KẾT QUẢ KIỂM TRA REFERENCE_ID ---');
console.log(`📊 Tổng số bản ghi trong bảng: ${totalCount}`);
console.log(`❌ Số bản ghi có reference_id bị NULL/Trống: ${nullRefCount}`);
console.log(`📈 Tỷ lệ lỗi: ${((nullRefCount / totalCount) * 100).toFixed(2)}%`);
if (statsByStatus.length > 0) {
console.log('\nPhân loại theo Status:');
statsByStatus.forEach(stat => {
console.log(` - Status ${stat.get('status')}: ${stat.get('count')} bản ghi`);
});
}
process.exit(0);
} catch (error) {
console.error('❌ Lỗi khi kiểm tra:', error);
process.exit(1);
}
}
checkNullRefId();

View File

@@ -0,0 +1,57 @@
const { Context, Vocab, Sentences } = require('../models');
const { sequelize } = require('../config/database');
async function fixReferenceIds() {
try {
await sequelize.authenticate();
console.log('✅ Kết nối database thành công.');
// Chỉ kiểm tra các bản ghi status 5 (hoặc bạn có thể bỏ where để check tất cả)
const contexts = await Context.findAll({
where: { status: 5 }
});
console.log(`🔍 Đang kiểm tra ${contexts.length} bản ghi Context để sửa Reference ID...`);
let fixedCount = 0;
let verifiedCount = 0;
for (const ctx of contexts) {
// 1. Thử tìm trong Vocab trước
const vocab = await Vocab.findOne({ where: { text: ctx.title } });
if (vocab) {
if (ctx.reference_id !== vocab.vocab_id) {
await ctx.update({ reference_id: vocab.vocab_id });
fixedCount++;
console.log(`✅ Fixed ID: '${ctx.title}' -> ${vocab.vocab_id} (Vocab)`);
} else {
verifiedCount++;
}
continue;
}
// 2. Nếu không thấy trong Vocab, thử tìm trong Sentences bằng trường context
const sentence = await Sentences.findOne({ where: { text: ctx.context } });
if (sentence) {
if (ctx.reference_id !== sentence.id) {
await ctx.update({ reference_id: sentence.id });
fixedCount++;
console.log(`✅ Fixed ID: '${ctx.title}' -> ${sentence.id} (Sentence)`);
} else {
verifiedCount++;
}
}
}
console.log('\n--- KẾT QUẢ SỬA LỖI ---');
console.log(`✅ Đã sửa: ${fixedCount} bản ghi.`);
console.log(`✔️ Đã chuẩn sẵn: ${verifiedCount} bản ghi.`);
process.exit(0);
} catch (error) {
console.error('❌ Lỗi:', error);
process.exit(1);
}
}
fixReferenceIds();

View File

@@ -0,0 +1,34 @@
const { Context } = require('../models');
const { Op } = require('sequelize');
const { sequelize } = require('../config/database');
async function migrate() {
try {
await sequelize.authenticate();
console.log('✅ Kết nối database thành công.');
const fromDate = new Date('2026-02-28T09:00:00Z');
console.log(`🔍 Tìm kiếm các context có status = 6 và update từ ${fromDate.toISOString()} trở đi...`);
const [affectedCount] = await Context.update(
{ status: 5 },
{
where: {
status: 6,
updated_at: {
[Op.gte]: fromDate
}
}
}
);
console.log(`✅ Đã cập nhật thành công ${affectedCount} bản ghi từ status 6 sang status 5.`);
process.exit(0);
} catch (error) {
console.error('❌ Lỗi khi migration:', error);
process.exit(1);
}
}
migrate();

Binary file not shown.

View File

@@ -0,0 +1,108 @@
const { Context, Vocab, Sentences } = require('../models');
const { sequelize } = require('../config/database');
// --- helper: xác định slot image từ tên file/URL ---
function resolveImageSlot(imageUrl) {
if (!imageUrl) return null;
const filename = imageUrl.split('/').pop().split('?')[0];
const parts = filename.split('_');
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';
}
// Nếu không tìm thấy trong underscore, thử tìm trong toàn bộ URL
if (imageUrl.toLowerCase().includes('square')) return 'image_square';
if (imageUrl.toLowerCase().includes('small')) return 'image_small';
if (imageUrl.toLowerCase().includes('normal')) return 'image_normal';
return null;
}
async function syncImages() {
try {
await sequelize.authenticate();
console.log('✅ Kết nối database thành công.');
const contexts = await Context.findAll({
where: { status: 5 }
});
console.log(`📦 Tìm thấy ${contexts.length} bản ghi Context ở status 5.`);
let vocabUpdated = 0;
let sentencesUpdated = 0;
let skipped = 0;
for (const ctx of contexts) {
const imageUrl = ctx.image;
const slot = resolveImageSlot(imageUrl);
if (!imageUrl || !slot) {
console.warn(`⚠️ ID ${ctx.uuid}: Không xác định được ảnh hoặc slot (URL: ${imageUrl}). Bỏ qua.`);
skipped++;
continue;
}
let updated = false;
// Ưu tiên 1: Dùng reference_id
if (ctx.reference_id) {
// Thử tìm trong Vocab
const vocab = await Vocab.findByPk(ctx.reference_id);
if (vocab) {
await vocab.update({ [slot]: [imageUrl] });
vocabUpdated++;
updated = true;
} else {
// Thử tìm trong Sentences
const sentence = await Sentences.findByPk(ctx.reference_id);
if (sentence) {
await sentence.update({ [slot]: [imageUrl] });
sentencesUpdated++;
updated = true;
}
}
}
// Ưu tiên 2: Nếu chưa update được (hoặc ko có ref_id), thử match theo text
if (!updated) {
// Thử khớp Vocab.text = Context.title
const vocabByText = await Vocab.findOne({ where: { text: ctx.title } });
if (vocabByText) {
await vocabByText.update({ [slot]: [imageUrl] });
vocabUpdated++;
updated = true;
} else if (ctx.context) {
// Thử khớp Sentences.text = Context.context
const sentenceByText = await Sentences.findOne({ where: { text: ctx.context } });
if (sentenceByText) {
await sentenceByText.update({ [slot]: [imageUrl] });
sentencesUpdated++;
updated = true;
}
}
}
if (updated) {
await ctx.update({ status: 6 });
} else {
console.warn(`❌ ID ${ctx.uuid}: Không tìm thấy entity đích để cập nhật ảnh (Title: ${ctx.title}).`);
skipped++;
}
}
console.log('\n--- KẾT QUẢ ĐỒNG BỘ ẢNH ---');
console.log(`✅ Cập nhật Vocab: ${vocabUpdated} bản ghi.`);
console.log(`✅ Cập nhật Sentences: ${sentencesUpdated} bản ghi.`);
console.log(`⚠️ Bỏ qua hoặc lỗi: ${skipped} bản ghi.`);
console.log(`📊 Tổng xử lý: ${contexts.length}`);
process.exit(0);
} catch (error) {
console.error('❌ Lỗi khi đồng bộ:', error);
process.exit(1);
}
}
syncImages();

View File

@@ -0,0 +1,67 @@
const { Context, Sentences } = require('../models');
const { Op } = require('sequelize');
const { sequelize } = require('../config/database');
async function syncRefIdFromSentences() {
try {
await sequelize.authenticate();
console.log('✅ Kết nối database thành công.');
// 1. Tìm các bản ghi status = 5 và reference_id đang null hoặc trống
const contexts = await Context.findAll({
where: {
status: 5,
[Op.or]: [
{ reference_id: null },
{ reference_id: '' }
]
}
});
console.log(`📊 Tìm thấy ${contexts.length} bản ghi status = 5 thiếu reference_id.`);
let updatedCount = 0;
let notFoundCount = 0;
for (const context of contexts) {
// Tìm câu trong bảng Sentences có text khớp chính xác với title của Context
const sentence = await Sentences.findOne({
where: {
text: context.title
}
});
if (sentence) {
await context.update({ reference_id: sentence.id });
updatedCount++;
// console.log(`✅ ID ${context.uuid}: Đã khớp '${context.title}' -> Sentence ID ${sentence.id}`);
} else {
notFoundCount++;
// console.log(`⚠️ ID ${context.uuid}: Không tìm thấy câu '${context.title}' trong bảng Sentences.`);
}
}
// 2. Kiểm tra lại số lượng còn sót sau khi update
const remainingCount = await Context.count({
where: {
status: 5,
[Op.or]: [
{ reference_id: null },
{ reference_id: '' }
]
}
});
console.log('\n--- KẾT QUẢ CẬP NHẬT REFERENCE_ID TỪ SENTENCES ---');
console.log(`✅ Đã cập nhật thành công: ${updatedCount} bản ghi.`);
console.log(`❌ Không tìm thấy Sentence khớp: ${notFoundCount} bản ghi.`);
console.log(`📊 Tổng số bản ghi status 5 vẫn còn thiếu reference_id: ${remainingCount}`);
process.exit(0);
} catch (error) {
console.error('❌ Lỗi khi migration:', error);
process.exit(1);
}
}
syncRefIdFromSentences();

View File

@@ -0,0 +1,67 @@
const { Context, Vocab } = require('../models');
const { Op } = require('sequelize');
const { sequelize } = require('../config/database');
async function syncRefIdFromVocab() {
try {
await sequelize.authenticate();
console.log('✅ Kết nối database thành công.');
// 1. Tìm các bản ghi status = 5 và reference_id đang null hoặc trống
const contexts = await Context.findAll({
where: {
status: 5,
[Op.or]: [
{ reference_id: null },
{ reference_id: '' }
]
}
});
console.log(`📊 Tìm thấy ${contexts.length} bản ghi status = 5 thiếu reference_id.`);
let updatedCount = 0;
let notFoundCount = 0;
for (const context of contexts) {
// Tìm từ trong bảng Vocab có text khớp chính xác với title của Context
const vocab = await Vocab.findOne({
where: {
text: context.title
}
});
if (vocab) {
await context.update({ reference_id: vocab.vocab_id });
updatedCount++;
// console.log(`✅ ID ${context.uuid}: Đã khớp '${context.title}' -> Vocab ID ${vocab.vocab_id}`);
} else {
notFoundCount++;
console.log(`⚠️ ID ${context.uuid}: Không tìm thấy từ '${context.title}' trong bảng Vocab.`);
}
}
// 2. Kiểm tra lại số lượng còn sót sau khi update
const remainingCount = await Context.count({
where: {
status: 5,
[Op.or]: [
{ reference_id: null },
{ reference_id: '' }
]
}
});
console.log('\n--- KẾT QUẢ CẬP NHẬT REFERENCE_ID ---');
console.log(`✅ Đã cập nhật thành công: ${updatedCount} bản ghi.`);
console.log(`❌ Không tìm thấy Vocab khớp: ${notFoundCount} bản ghi.`);
console.log(`📊 Tổng số bản ghi status 5 vẫn còn thiếu reference_id: ${remainingCount}`);
process.exit(0);
} catch (error) {
console.error('❌ Lỗi khi migration:', error);
process.exit(1);
}
}
syncRefIdFromVocab();

View File

@@ -0,0 +1,70 @@
const { Context } = require('../models');
const { Op } = require('sequelize');
const { sequelize } = require('../config/database');
async function updateTypeImage() {
try {
await sequelize.authenticate();
console.log('✅ Kết nối database thành công.');
// 1. Tìm các bản ghi status = 5 và type_image đang null
const contexts = await Context.findAll({
where: {
status: 5,
[Op.or]: [
{ type_image: null },
{ type_image: '' }
]
}
});
console.log(`📊 Tìm thấy ${contexts.length} bản ghi status = 5 thiếu type_image.`);
let updatedCount = 0;
let skippedCount = 0;
for (const context of contexts) {
if (!context.image) {
skippedCount++;
continue;
}
// Logic trích xuất type_image từ URL: https://image.senaai.tech/type/filename.jpg
// Ví dụ: https://image.senaai.tech/square/cool_square_1772002797233.jpg -> type là 'square'
const match = context.image.match(/image\.senaai\.tech\/([^/]+)\//);
if (match && match[1]) {
const extractedType = match[1];
await context.update({ type_image: extractedType });
updatedCount++;
console.log(`✅ ID ${context.id}: Cập nhật type_image = '${extractedType}' từ URL.`);
} else {
skippedCount++;
console.log(`⚠️ ID ${context.id}: Không thể trích xuất type từ URL: ${context.image}`);
}
}
// 2. Kiểm tra xem còn sót cái nào không
const remainingCount = await Context.count({
where: {
status: 5,
[Op.or]: [
{ type_image: null },
{ type_image: '' }
]
}
});
console.log('\n--- KẾT QUẢ ---');
console.log(`✅ Đã cập nhật: ${updatedCount} bản ghi.`);
console.log(`⏭️ Bỏ qua (không có ảnh hoặc URL không khớp): ${skippedCount} bản ghi.`);
console.log(`❌ Số lượng còn sót lại vẫn thiếu type_image: ${remainingCount} bản ghi.`);
process.exit(0);
} catch (error) {
console.error('❌ Lỗi khi cập nhật:', error);
process.exit(1);
}
}
updateTypeImage();

View File

@@ -2,102 +2,107 @@ const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database'); const { sequelize } = require('../config/database');
const Sentences = sequelize.define('Sentences', { const Sentences = sequelize.define('Sentences', {
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true, primaryKey: true,
comment: 'Unique identifier for sentence entry' 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.)'
},
status: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: 'Trạng thái confirm (0 = chưa confirm)'
},
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 }]
}, },
// Từ thực tế (wash, washes, washing, ate, eaten...) {
text: { name: 'idx_category',
type: DataTypes.TEXT, fields: [{ name: 'category', length: 191 }]
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; module.exports = Sentences;

View File

@@ -3,121 +3,131 @@ const { sequelize } = require('../config/database');
const Grade = require('./Grade'); const Grade = require('./Grade');
const Vocab = sequelize.define('Vocab', { const Vocab = sequelize.define('Vocab', {
vocab_id: { vocab_id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true, primaryKey: true,
comment: 'Unique identifier for vocabulary entry' comment: 'Unique identifier for vocabulary entry'
},
// Từ thực tế (wash, washes, washing, ate, eaten...)
text: {
type: DataTypes.STRING(100),
allowNull: false,
index: true
},
ipa: {
type: DataTypes.STRING(100),
comment: 'International Phonetic Alphabet representation'
},
// Từ gốc để nhóm lại (wash, eat...)
base_word: {
type: DataTypes.STRING(100),
allowNull: false,
index: true
},
// Loại biến thể (V1, V2, V3, V_ing, Noun_Form...)
form_key: {
type: DataTypes.TEXT,
defaultValue: 'base',
comment: 'Form key indicating the type of word form (e.g., base, V1, V2, V3, V_ing, Noun_Form)'
},
// 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_number: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: 'Numeric representation of grade unit lesson as GG UU LL for easier querying and sorting'
},
category: {
type: DataTypes.STRING(100),
comment: 'Category of the word (e.g., Action Verbs, Nouns)'
},
topic: {
type: DataTypes.STRING(100),
comment: 'Topic of the word (e.g., Food, Travel, Education)'
},
image_small: {
type: DataTypes.JSON,
comment: 'Array of image URLs'
},
image_square: {
type: DataTypes.JSON,
comment: 'Array of image URLs'
},
image_normal: {
type: DataTypes.JSON,
comment: 'Array of image URLs'
},
audio: {
type: DataTypes.JSON,
comment: 'Array of audio URLs'
},
example_sentences: {
type: DataTypes.JSON,
comment: 'Array of example sentences'
},
tags: {
type: DataTypes.JSON,
comment: 'Array of tags for categorization'
},
syntax: {
type: DataTypes.JSON,
comment: 'Syntax roles for Grammar Engine (is_subject, is_verb, is_object, is_be, is_adj, is_adv, is_article, verb_type, article_type, adv_type, position, priority, etc.)'
},
semantics: {
type: DataTypes.JSON,
comment: 'Semantic constraints (can_be_subject_type, can_take_object_type, can_modify, cannot_modify, word_type, is_countable, person_type, etc.)'
},
constraints: {
type: DataTypes.JSON,
comment: 'Grammar constraints (followed_by, match_subject, match_with, phonetic_rules, etc.)'
},
status: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: 'Trạng thái confirm (0 = chưa confirm)'
},
etc: {
type: DataTypes.TEXT,
defaultValue: '',
comment: 'Thông tin bổ sung'
},
is_active: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: 'Whether this vocab entry is active'
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
},
updated_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
}
}, {
tableName: 'vocab',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{
name: 'idx_vocab_text',
fields: ['text']
}, },
// Từ thực tế (wash, washes, washing, ate, eaten...) {
text: { name: 'idx_base_word',
type: DataTypes.STRING(100), fields: ['base_word']
allowNull: false,
index: true
}, },
ipa : { {
type: DataTypes.STRING(100), name: 'idx_category',
comment: 'International Phonetic Alphabet representation' fields: ['category']
},
// Từ gốc để nhóm lại (wash, eat...)
base_word: {
type: DataTypes.STRING(100),
allowNull: false,
index: true
},
// Loại biến thể (V1, V2, V3, V_ing, Noun_Form...)
form_key: {
type: DataTypes.TEXT,
defaultValue: 'base',
comment: 'Form key indicating the type of word form (e.g., base, V1, V2, V3, V_ing, Noun_Form)'
},
// 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_number: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: 'Numeric representation of grade unit lesson as GG UU LL for easier querying and sorting'
},
category: {
type: DataTypes.STRING(100),
comment: 'Category of the word (e.g., Action Verbs, Nouns)'
},
topic: {
type: DataTypes.STRING(100),
comment: 'Topic of the word (e.g., Food, Travel, Education)'
},
image_small: {
type: DataTypes.JSON,
comment: 'Array of image URLs'
},
image_square: {
type: DataTypes.JSON,
comment: 'Array of image URLs'
},
image_normal: {
type: DataTypes.JSON,
comment: 'Array of image URLs'
},
audio : {
type: DataTypes.JSON,
comment: 'Array of audio URLs'
},
example_sentences: {
type: DataTypes.JSON,
comment: 'Array of example sentences'
},
tags: {
type: DataTypes.JSON,
comment: 'Array of tags for categorization'
},
syntax: {
type: DataTypes.JSON,
comment: 'Syntax roles for Grammar Engine (is_subject, is_verb, is_object, is_be, is_adj, is_adv, is_article, verb_type, article_type, adv_type, position, priority, etc.)'
},
semantics: {
type: DataTypes.JSON,
comment: 'Semantic constraints (can_be_subject_type, can_take_object_type, can_modify, cannot_modify, word_type, is_countable, person_type, etc.)'
},
constraints: {
type: DataTypes.JSON,
comment: 'Grammar constraints (followed_by, match_subject, match_with, phonetic_rules, etc.)'
},
is_active: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: 'Whether this vocab entry is active'
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
},
updated_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
} }
}, { ]
tableName: 'vocab', });
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{
name: 'idx_vocab_text',
fields: ['text']
},
{
name: 'idx_base_word',
fields: ['base_word']
},
{
name: 'idx_category',
fields: ['category']
}
]
});
module.exports = Vocab; module.exports = Vocab;