const { Context, Vocab } = require('../models'); const { Op } = require('sequelize'); /** * Context Controller - Workflow-based status management * Status flow: 0 (Draft) -> 1 (Enriched) -> 2 (Prompt Ready) -> 3 (Generating) -> 4 (Image Ready) -> 5 (Approved) */ class ContextController { /** * Create new context - Status 0 (Draft) * Required fields: title, desc, grade */ async createContext(req, res, next) { try { const { title, desc, grade, type, type_image, reference_id } = req.body; // Validate required fields if (!title || !desc || !grade) { return res.status(400).json({ success: false, message: 'Title, desc, and grade are required' }); } // Validate grade format (gradeX100 + unitX10 + lesson) const gradeNum = parseInt(grade); if (isNaN(gradeNum) || gradeNum < 100) { return res.status(400).json({ success: false, message: 'Grade must be in format: gradeX100 + unitX10 + lesson (e.g., 123 for Grade 1 Unit 2 Lesson 3)' }); } const context = await Context.create({ title, desc, grade: gradeNum, type: type || 'general', type_image: type_image || null, status: 0, // Draft context: '', knowledge: '', reference_id: reference_id || null }); res.status(201).json({ success: true, message: 'Context created successfully', data: context }); } catch (error) { next(error); } } /** * Get contexts by status */ async getContextsByStatus(req, res, next) { try { const { status } = req.params; const { page = 1, limit = 50, type, grade, title, date, from, to, sort } = req.query; const offset = (page - 1) * limit; const where = { status: parseInt(status) }; if (type) where.type = type; 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({ where, limit: parseInt(limit), offset: parseInt(offset), order }); res.json({ success: true, data: { contexts: rows, pagination: { total: count, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(count / limit) } } }); } catch (error) { next(error); } } /** * Update knowledge - Status 0 -> 1 (Enriched) */ async enrichContext(req, res, next) { try { const { id } = req.params; const { knowledge } = req.body; if (!knowledge) { return res.status(400).json({ success: false, message: 'Knowledge is required' }); } const context = await Context.findByPk(id); if (!context) { return res.status(404).json({ success: false, message: 'Context not found' }); } if (context.status !== 0) { return res.status(400).json({ success: false, message: 'Context must be in Draft status (0) to enrich' }); } await context.update({ knowledge, status: 1 }); res.json({ success: true, message: 'Context enriched successfully', data: context }); } catch (error) { next(error); } } /** * Update context and img_prompt - Status 1 -> 2 (Prompt Ready) */ async preparePrompt(req, res, next) { try { const { id } = req.params; const { context, img_prompt } = req.body; if (!context || !img_prompt) { return res.status(400).json({ success: false, message: 'Context and img_prompt are required' }); } const contextRecord = await Context.findByPk(id); if (!contextRecord) { return res.status(404).json({ success: false, message: 'Context not found' }); } if (contextRecord.status !== 1) { return res.status(400).json({ success: false, message: 'Context must be in Enriched status (1) to prepare prompt' }); } await contextRecord.update({ context, img_prompt, status: 2 }); res.json({ success: true, message: 'Prompt prepared successfully', data: contextRecord }); } catch (error) { next(error); } } /** * Update status to 3 (Generating) or back to 1 (Enriched) */ async updateStatusFromPromptReady(req, res, next) { try { const { id } = req.params; const { status } = req.body; const context = await Context.findByPk(id); if (!context) { return res.status(404).json({ success: false, message: 'Context not found' }); } await context.update({ status: parseInt(status) }); res.json({ success: true, message: `Status updated to ${status}`, data: context }); } catch (error) { next(error); } } /** * Bulk update all status 2 to status 3 (Prompt Ready -> Generating) */ async bulkUpdateStatus2To3(req, res, next) { try { const [affectedCount] = await Context.update( { status: 3 }, { where: { status: 2 } } ); res.json({ success: true, message: `Updated ${affectedCount} context(s) from status 2 to status 3`, data: { affectedCount } }); } catch (error) { next(error); } } /** * Add images - Status 3 -> 4 (Image Ready) */ async addImages(req, res, next) { try { const { id } = req.params; const { image } = req.body; if (!image || typeof image !== 'string' || image.trim().length === 0) { return res.status(400).json({ success: false, message: 'Image must be a non-empty string (URL)' }); } const context = await Context.findByPk(id); if (!context) { return res.status(404).json({ success: false, message: 'Context not found' }); } if (context.status !== 3) { return res.status(400).json({ success: false, message: 'Context must be in Generating status (3) to add images' }); } await context.update({ image, status: 4 }); res.json({ success: true, message: 'Images added successfully', data: context }); } catch (error) { next(error); } } /** * Approve context - Status 4 -> 5 (Approved) */ async approveContext(req, res, next) { try { const { id } = req.params; const context = await Context.findByPk(id); if (!context) { return res.status(404).json({ success: false, message: 'Context not found' }); } if (context.status !== 4) { return res.status(400).json({ success: false, message: 'Context must be in Image Ready status (4) to approve' }); } // add image to Vocab Image const currentVocab = await Vocab.findOne({ where: { vocab_id: context.reference_id } }); console.log('Current Vocab:', currentVocab); if (currentVocab) { if (context.type_image === 'small') { const updatedImagesSmall = currentVocab.image_small || []; updatedImagesSmall.push(context.image); await currentVocab.update({ image_small: updatedImagesSmall }); } else if (context.type_image === 'square') { const updatedImagesSquare = currentVocab.image_square || []; updatedImagesSquare.push(context.image); await currentVocab.update({ image_square: updatedImagesSquare }); } else if (context.type_image === 'normal') { const updatedImagesNormal = currentVocab.image_normal || []; updatedImagesNormal.push(context.image); await currentVocab.update({ image_normal: updatedImagesNormal }); } await currentVocab.save(); } await context.update({ status: 5 }); res.json({ success: true, message: 'Context approved successfully', data: context }); } catch (error) { next(error); } } /** * Get context by UUID (for viewing details) */ async getContextById(req, res, next) { try { const { id } = req.params; const context = await Context.findByPk(id); if (!context) { return res.status(404).json({ success: false, message: 'Context not found' }); } res.json({ success: true, data: context }); } catch (error) { next(error); } } /** * Get all contexts with pagination and filters */ async getAllContexts(req, res, next) { try { const { page = 1, limit = 50, type, grade, status } = req.query; const offset = (page - 1) * limit; const where = {}; if (type) where.type = type; if (grade) where.grade = parseInt(grade); if (status !== undefined) where.status = parseInt(status); const { count, rows } = await Context.findAndCountAll({ where, limit: parseInt(limit), offset: parseInt(offset), order: [['created_at', 'DESC']] }); res.json({ success: true, data: { contexts: rows, pagination: { total: count, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(count / limit) } } }); } catch (error) { next(error); } } /** * 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, reference_id, page = 1, limit = 50 } = req.body; 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); if (reference_id) where.reference_id = reference_id; // ── 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) */ async updateContext(req, res, next) { try { const { id } = req.params; const updates = req.body; const context = await Context.findByPk(id); if (!context) { return res.status(404).json({ success: false, message: 'Context not found' }); } await context.update(updates); res.json({ success: true, message: 'Context updated successfully', data: context }); } catch (error) { next(error); } } /** * Delete context */ async deleteContext(req, res, next) { try { const { id } = req.params; const context = await Context.findByPk(id); if (!context) { return res.status(404).json({ success: false, message: 'Context not found' }); } await context.destroy(); res.json({ success: true, message: 'Context deleted successfully' }); } catch (error) { next(error); } } } module.exports = new ContextController();