const { Story, sequelize } = require('../models'); const { Op } = require('sequelize'); const { Lesson, LessonStory } = require('../models'); const { cacheUtils } = require('../config/redis'); /** * CREATE: Add new story */ exports.createStory = async (req, res) => { const transaction = await sequelize.transaction(); try { const { name, logo, vocabulary = [], context = [], grade = [], grade_number = 0, type = 'story', tag = [] } = req.body; // Validate required fields if (!name) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Missing required field: name' }); } // Create story const story = await Story.create({ name, logo, vocabulary, context, grade, grade_number, type, tag }, { transaction }); await transaction.commit(); res.status(201).json({ success: true, message: 'Story created successfully', data: story }); } catch (error) { await transaction.rollback(); console.error('Error creating story:', error); res.status(500).json({ success: false, message: 'Failed to create story', error: error.message }); } }; /** * READ: Get all stories with pagination and filters */ exports.getAllStories = async (req, res) => { try { const { page = 1, limit = 20, search, grade_filter, tag_filter, type, grade_start, grade_end, sort_by = 'created_at', sort_order = 'DESC' } = req.query; const offset = (parseInt(page) - 1) * parseInt(limit); const where = {}; // Search filter if (search) { where.name = { [Op.like]: `%${search}%` }; } // Type filter (exact match) if (type) { where.type = type; } // Grade number range filter if (grade_start !== undefined && grade_end !== undefined) { const s = parseInt(grade_start); const e = parseInt(grade_end); if (s === e) { where.grade_number = s; } else if (s < e) { where.grade_number = { [Op.between]: [s, e] }; } } else if (grade_start !== undefined) { where.grade_number = { [Op.gte]: parseInt(grade_start) }; } else if (grade_end !== undefined) { where.grade_number = { [Op.lte]: parseInt(grade_end) }; } // Grade filter (legacy JSON array search) if (grade_filter) { where.grade = { [Op.contains]: [grade_filter] }; } // Tag filter if (tag_filter) { where.tag = { [Op.contains]: [tag_filter] }; } const { count, rows } = await Story.findAndCountAll({ where, limit: parseInt(limit), offset, order: [[sort_by, sort_order.toUpperCase()]] }); res.json({ success: true, data: rows, pagination: { total: count, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(count / parseInt(limit)) } }); } catch (error) { console.error('Error fetching stories:', error); res.status(500).json({ success: false, message: 'Failed to fetch stories', error: error.message }); } }; /** * READ: Get story by ID */ exports.getStoryById = async (req, res) => { try { const { id } = req.params; const story = await Story.findByPk(id); if (!story) { return res.status(404).json({ success: false, message: 'Story not found' }); } res.json({ success: true, data: story }); } catch (error) { console.error('Error fetching story:', error); res.status(500).json({ success: false, message: 'Failed to fetch story', error: error.message }); } }; /** * UPDATE: Update story */ exports.updateStory = async (req, res) => { const transaction = await sequelize.transaction(); try { const { id } = req.params; const { name, logo, vocabulary, context, grade, grade_number, type, tag } = req.body; const story = await Story.findByPk(id); if (!story) { await transaction.rollback(); return res.status(404).json({ success: false, message: 'Story not found' }); } // Update fields const updateData = {}; if (name !== undefined) updateData.name = name; if (logo !== undefined) updateData.logo = logo; if (vocabulary !== undefined) updateData.vocabulary = vocabulary; if (context !== undefined) updateData.context = context; if (grade !== undefined) updateData.grade = grade; if (grade_number !== undefined) updateData.grade_number = grade_number; if (type !== undefined) updateData.type = type; if (tag !== undefined) updateData.tag = tag; await story.update(updateData, { transaction }); await transaction.commit(); res.json({ success: true, message: 'Story updated successfully', data: story }); } catch (error) { await transaction.rollback(); console.error('Error updating story:', error); res.status(500).json({ success: false, message: 'Failed to update story', error: error.message }); } }; /** * DELETE: Delete story */ exports.deleteStory = async (req, res) => { try { const { id } = req.params; const story = await Story.findByPk(id); if (!story) { return res.status(404).json({ success: false, message: 'Story not found' }); } await story.destroy(); res.json({ success: true, message: 'Story deleted successfully' }); } catch (error) { console.error('Error deleting story:', error); res.status(500).json({ success: false, message: 'Failed to delete story', error: error.message }); } }; /** * READ: Get stories by grade */ exports.getStoriesByGrade = async (req, res) => { try { const { grade, start, end } = req.query; const where = {}; // grade_number range search if (start !== undefined && end !== undefined) { const s = parseInt(start); const e = parseInt(end); if (s === e) { where.grade_number = s; } else if (s < e) { where.grade_number = { [Op.between]: [s, e] }; } } else if (start !== undefined) { where.grade_number = { [Op.gte]: parseInt(start) }; } else if (end !== undefined) { where.grade_number = { [Op.lte]: parseInt(end) }; } else if (grade) { // Legacy: search by JSON array where.grade = { [Op.contains]: [grade] }; } else { return res.status(400).json({ success: false, message: 'Provide grade, or start/end parameters' }); } const stories = await Story.findAll({ where, order: [['grade_number', 'ASC'], ['created_at', 'DESC']], attributes: ['id', 'name', 'logo', 'type', 'grade', 'grade_number', 'tag', 'created_at'] }); res.json({ success: true, data: stories, count: stories.length }); } catch (error) { console.error('Error fetching stories by grade:', error); res.status(500).json({ success: false, message: 'Failed to fetch stories by grade', error: error.message }); } }; /** * READ: Get stories by tag */ exports.getStoriesByTag = async (req, res) => { try { const { tag } = req.query; if (!tag) { return res.status(400).json({ success: false, message: 'Tag parameter is required' }); } const stories = await Story.findAll({ where: { tag: { [Op.contains]: [tag] } }, order: [['created_at', 'DESC']], attributes: ['id', 'name', 'logo', 'grade', 'tag', 'created_at'] }); res.json({ success: true, data: stories, count: stories.length }); } catch (error) { console.error('Error fetching stories by tag:', error); res.status(500).json({ success: false, message: 'Failed to fetch stories by tag', error: error.message }); } }; /** * READ: Get stories by type */ exports.getStoriesByType = async (req, res) => { try { const { type } = req.query; if (!type) { return res.status(400).json({ success: false, message: 'Type parameter is required' }); } const stories = await Story.findAll({ where: { type }, order: [['grade_number', 'ASC'], ['created_at', 'DESC']] }); res.json({ success: true, data: stories, count: stories.length }); } catch (error) { console.error('Error fetching stories by type:', error); res.status(500).json({ success: false, message: 'Failed to fetch stories by type', error: error.message }); } }; /** * READ: Get story statistics */ exports.getStoryStats = async (req, res) => { try { const totalStories = await Story.count(); // Count by type (SQL GROUP BY) const typeRows = await sequelize.query( `SELECT type, COUNT(*) as count FROM stories GROUP BY type ORDER BY count DESC`, { type: sequelize.QueryTypes.SELECT } ); // Count by grade_number (SQL GROUP BY) const gradeNumberRows = await sequelize.query( `SELECT grade_number, COUNT(*) as count FROM stories GROUP BY grade_number ORDER BY grade_number ASC`, { type: sequelize.QueryTypes.SELECT } ); // Get all stories to analyze legacy grade array and tags const allStories = await Story.findAll({ attributes: ['grade', 'tag'] }); // Count by legacy grade array const gradeStats = {}; allStories.forEach(story => { if (story.grade && Array.isArray(story.grade)) { story.grade.forEach(g => { gradeStats[g] = (gradeStats[g] || 0) + 1; }); } }); // Count by tag const tagStats = {}; allStories.forEach(story => { if (story.tag && Array.isArray(story.tag)) { story.tag.forEach(t => { tagStats[t] = (tagStats[t] || 0) + 1; }); } }); res.json({ success: true, data: { total: totalStories, by_type: typeRows.map(r => ({ type: r.type || 'story', count: parseInt(r.count) })), by_grade_number: gradeNumberRows.map(r => ({ grade_number: r.grade_number, count: parseInt(r.count) })), by_grade: Object.entries(gradeStats).map(([grade, count]) => ({ grade, count })), by_tag: Object.entries(tagStats).map(([tag, count]) => ({ tag, count })) } }); } catch (error) { console.error('Error fetching story stats:', error); res.status(500).json({ success: false, message: 'Failed to fetch story statistics', error: error.message }); } }; /** * GET GUIDE: Comprehensive guide for AI to create stories */ exports.getStoryGuide = async (req, res) => { try { const guide = { guide_version: "1.0.0", last_updated: new Date().toISOString(), data_structure: { required_fields: { name: { type: "string(255)", example: "The Greedy Cat", description: "Story title/name" } }, optional_fields: { logo: { type: "text (URL)", example: "https://cdn.sena.tech/thumbs/greedy-cat.jpg", description: "URL to story logo/thumbnail image" }, vocabulary: { type: "JSON array", example: ["cat", "eat", "apple", "happy"], description: "Array of vocabulary words used in the story" }, context: { type: "JSON array of objects", description: "Array of story context objects with images, text, audio data", item_structure: { image: "URL to context image", text: "Story text for this context", audio: "URL to audio narration", order: "Sequence number (integer)" }, example: [ { image: "https://cdn.sena.tech/story/scene1.jpg", text: "Once upon a time, there was a greedy cat.", audio: "https://cdn.sena.tech/audio/scene1.mp3", order: 1 }, { image: "https://cdn.sena.tech/story/scene2.jpg", text: "The cat loved eating apples.", audio: "https://cdn.sena.tech/audio/scene2.mp3", order: 2 } ] }, grade: { type: "JSON array", example: ["Grade 1", "Grade 2", "Grade 3"], description: "Array of grade levels this story is suitable for" }, tag: { type: "JSON array", example: ["adventure", "friendship", "animals", "food"], description: "Array of tags for categorization and filtering" } } }, context_structure: { description: "Each context object represents a scene/page in the story", required_properties: { text: "Story text content (required)", order: "Sequence number starting from 1 (required)" }, optional_properties: { image: "URL to scene image", audio: "URL to audio narration" }, best_practices: [ "Keep text concise and age-appropriate", "Use order numbers sequentially (1, 2, 3, ...)", "Provide audio for better engagement", "Images should be high quality and relevant" ] }, vocabulary_guidelines: { description: "Link vocabulary words to existing vocab entries", format: "Array of strings", tips: [ "Use vocab_code from Vocab table for better tracking", "Include only words that appear in the story", "Order by frequency or importance", "Can reference vocab IDs or codes" ], example: ["vocab-001-cat", "vocab-002-eat", "vocab-015-apple"] }, grade_levels: { description: "Supported grade levels", options: [ "Pre-K", "Kindergarten", "Grade 1", "Grade 2", "Grade 3", "Grade 4", "Grade 5", "Grade 6" ], tips: [ "Can assign multiple grades", "Consider vocabulary difficulty", "Match with curriculum standards" ] }, tag_categories: { description: "Common tag categories for story classification", categories: { themes: ["adventure", "friendship", "family", "courage", "honesty", "kindness"], subjects: ["animals", "nature", "food", "school", "home", "travel"], skills: ["reading", "listening", "vocabulary", "grammar", "phonics"], emotions: ["happy", "sad", "excited", "scared", "surprised"], genres: ["fiction", "non-fiction", "fantasy", "realistic", "fable"] }, tips: [ "Use 3-7 tags per story", "Mix different category types", "Keep tags consistent across stories", "Use lowercase for consistency" ] }, examples: { complete_story: { name: "The Greedy Cat", logo: "https://cdn.sena.tech/thumbs/greedy-cat.jpg", vocabulary: ["cat", "eat", "apple", "happy", "greedy"], context: [ { image: "https://cdn.sena.tech/story/gc-scene1.jpg", text: "Once upon a time, there was a greedy cat.", audio: "https://cdn.sena.tech/audio/gc-scene1.mp3", order: 1 }, { image: "https://cdn.sena.tech/story/gc-scene2.jpg", text: "The cat loved eating apples every day.", audio: "https://cdn.sena.tech/audio/gc-scene2.mp3", order: 2 }, { image: "https://cdn.sena.tech/story/gc-scene3.jpg", text: "One day, the cat ate too many apples and felt sick.", audio: "https://cdn.sena.tech/audio/gc-scene3.mp3", order: 3 }, { image: "https://cdn.sena.tech/story/gc-scene4.jpg", text: "The cat learned to eat just enough and was happy again.", audio: "https://cdn.sena.tech/audio/gc-scene4.mp3", order: 4 } ], grade: ["Grade 1", "Grade 2"], tag: ["animals", "food", "lesson", "health", "fiction"] }, minimal_story: { name: "My Pet Dog", context: [ { text: "I have a pet dog.", order: 1 }, { text: "My dog is brown.", order: 2 }, { text: "I love my dog.", order: 3 } ] } }, validation_checklist: [ "✓ name is provided and descriptive", "✓ context array has at least 1 scene", "✓ Each context has text and order", "✓ order numbers are sequential (1, 2, 3...)", "✓ vocabulary words match story content", "✓ grade levels are appropriate for content", "✓ tags are relevant and descriptive", "✓ URLs are accessible (logo, images, audio)", "✓ Text is age-appropriate", "✓ Story has clear beginning, middle, end" ], common_mistakes: [ { mistake: "Missing context order", example: { context: [{ text: "Some text" }] }, fix: { context: [{ text: "Some text", order: 1 }] }, explanation: "Every context must have an order number" }, { mistake: "Non-sequential order numbers", example: { context: [{ order: 1 }, { order: 3 }, { order: 2 }] }, fix: { context: [{ order: 1 }, { order: 2 }, { order: 3 }] }, explanation: "Order should be sequential for proper story flow" }, { mistake: "Invalid grade format", example: { grade: "Grade 1" }, fix: { grade: ["Grade 1"] }, explanation: "grade must be an array" }, { mistake: "Mismatched vocabulary", example: { name: "The Cat", vocabulary: ["dog", "bird"] }, fix: { name: "The Cat", vocabulary: ["cat"] }, explanation: "Vocabulary should match words in the story" }, { mistake: "Missing story name", example: { context: [/* context items */] }, fix: { name: "My Story", context: [/* context items */] }, explanation: "name is required" } ], ai_tips: { content_creation: "Generate age-appropriate, engaging stories with clear moral lessons", vocabulary_integration: "Reference existing vocabulary entries from Vocab table", multimedia: "Always provide audio URLs for better learning engagement", sequencing: "Ensure context order is logical and sequential", testing: "Test story flow by reading context in order", consistency: "Use consistent naming conventions for URLs and vocabulary" }, api_usage: { create: "POST /api/stories", get_all: "GET /api/stories?page=1&limit=20&grade_filter=Grade 1&tag_filter=animals", get_one: "GET /api/stories/:id", update: "PUT /api/stories/:id", delete: "DELETE /api/stories/:id", by_grade: "GET /api/stories/grade?grade=Grade 1", by_tag: "GET /api/stories/tag?tag=adventure", stats: "GET /api/stories/stats", guide: "GET /api/stories/guide" } }; res.json({ success: true, data: guide }); } catch (error) { console.error('Error generating story guide:', error); res.status(500).json({ success: false, message: 'Failed to generate story guide', error: error.message }); } }; /** * Lấy danh sách lessons sử dụng story này */ exports.getLessonsByStory = async (req, res) => { try { const { storyId } = req.params; const { page = 1, limit = 20 } = req.query; const offset = (parseInt(page) - 1) * parseInt(limit); const cacheKey = `story:${storyId}:lessons:${page}:${limit}`; const cached = await cacheUtils.get(cacheKey); if (cached) { return res.json({ success: true, data: cached, cached: true, }); } // Check if story exists const story = await Story.findByPk(storyId); if (!story) { return res.status(404).json({ success: false, message: 'Story not found', }); } // Get lessons with pivot data const { count, rows } = await Lesson.findAndCountAll({ include: [ { model: Story, as: 'stories', where: { id: storyId }, through: { attributes: ['display_order', 'is_required'], }, attributes: [], }, ], limit: parseInt(limit), offset: parseInt(offset), order: [[{ model: Story, as: 'stories' }, LessonStory, 'display_order', 'ASC']], }); const result = { story: { id: story.id, name: story.name, type: story.type, }, lessons: rows.map(lesson => ({ ...lesson.toJSON(), display_order: lesson.stories[0]?.LessonStory?.display_order, is_required: lesson.stories[0]?.LessonStory?.is_required, })), pagination: { total: count, page: parseInt(page), limit: parseInt(limit), totalPages: Math.ceil(count / parseInt(limit)), }, }; await cacheUtils.set(cacheKey, result, 1800); res.json({ success: true, data: result, cached: false, }); } catch (error) { console.error('Error fetching lessons by story:', error); res.status(500).json({ success: false, message: 'Failed to fetch lessons', error: error.message, }); } }; /** * Thêm lesson vào story (alternative way) */ exports.addLessonToStory = async (req, res) => { try { const { storyId } = req.params; const { lesson_id, display_order = 0, is_required = true } = req.body; // Validate required fields if (!lesson_id) { return res.status(400).json({ success: false, message: 'lesson_id is required', }); } // Check if story exists const story = await Story.findByPk(storyId); if (!story) { return res.status(404).json({ success: false, message: 'Story not found', }); } // Check if lesson exists const lesson = await Lesson.findByPk(lesson_id); if (!lesson) { return res.status(404).json({ success: false, message: 'Lesson not found', }); } // Check if relationship already exists const existing = await LessonStory.findOne({ where: { lesson_id: lesson_id, story_id: storyId, }, }); if (existing) { return res.status(400).json({ success: false, message: 'Lesson đã sử dụng story này', }); } // Create relationship const lessonStory = await LessonStory.create({ lesson_id: lesson_id, story_id: storyId, display_order, is_required, }); // Clear cache await cacheUtils.deletePattern(`story:${storyId}:lessons:*`); await cacheUtils.deletePattern(`lesson:${lesson_id}:stories:*`); res.status(201).json({ success: true, message: 'Lesson đã được thêm vào story', data: lessonStory, }); } catch (error) { console.error('Error adding lesson to story:', error); res.status(500).json({ success: false, message: 'Failed to add lesson to story', error: error.message, }); } }; /** * Xóa lesson khỏi story */ exports.removeLessonFromStory = async (req, res) => { try { const { storyId, lessonId } = req.params; // Check if story exists const story = await Story.findByPk(storyId); if (!story) { return res.status(404).json({ success: false, message: 'Story not found', }); } // Find relationship const lessonStory = await LessonStory.findOne({ where: { lesson_id: lessonId, story_id: storyId, }, }); if (!lessonStory) { return res.status(404).json({ success: false, message: 'Lesson không sử dụng story này', }); } await lessonStory.destroy(); // Clear cache await cacheUtils.deletePattern(`story:${storyId}:lessons:*`); await cacheUtils.deletePattern(`lesson:${lessonId}:stories:*`); res.json({ success: true, message: 'Lesson đã được xóa khỏi story', }); } catch (error) { console.error('Error removing lesson from story:', error); res.status(500).json({ success: false, message: 'Failed to remove lesson from story', error: error.message, }); } };