const Blog = require("../models/blog"); const BlogCategory = require("../models/blogCategory"); const BlogTag = require("../models/blogTag"); const BlogComment = require("../models/blogComment"); const RecentPost = require("../models/recentPost"); const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper"); const slugify = require("slugify"); const writeAuditLog = require("../audit/writeAuditLog"); const diffObject = require("../audit/diffObject"); const AUDIT_ACTIONS = require("../constants/auditAction"); // -------------------- Helper Functions -------------------- // Generate slug from title const generateSlug = (title) => { return slugify(title, { lower: true, strict: true, locale: "vi", }); }; // Update category post counts const updateCategoryPostCounts = async () => { const categories = await BlogCategory.find(); for (const category of categories) { await category.updatePostCount(); } }; // Update tag post counts const updateTagPostCounts = async () => { const tags = await BlogTag.find(); for (const tag of tags) { await tag.updatePostCount(); } }; // -------------------- Admin Controllers -------------------- // Display blog management page exports.index = async (req, res) => { try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const skip = (page - 1) * limit; // Build filter const filter = {}; if (req.query.status) { filter.status = req.query.status; } if (req.query.category) { filter.category = req.query.category; } if (req.query.search) { filter.$or = [ { title: { $regex: req.query.search, $options: "i" } }, { excerpt: { $regex: req.query.search, $options: "i" } }, ]; } // Get blogs with pagination const blogs = await Blog.find(filter) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) .lean(); const totalBlogs = await Blog.countDocuments(filter); const totalPages = Math.ceil(totalBlogs / limit); // Get categories for filter const categories = await BlogCategory.getActive(); const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; res.render("admin/blog/index", { layout: "layouts/main", title: "Blog Management", blogs, categories, frontendUrl, backendUrl, getFullImageUrl, // Truyền helper function vào template pagination: { current: page, total: totalPages, limit, totalItems: totalBlogs, }, query: req.query, currentPath: req.path, user: req.session.user, }); } catch (err) { console.error("Blog index error:", err); req.flash("error_msg", "Error loading blogs"); res.redirect("/admin/dashboard"); } }; // Show create blog form exports.create = async (req, res) => { try { const categories = await BlogCategory.getActive(); const tags = await BlogTag.getActive(); const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; res.render("admin/blog/create", { layout: "layouts/main", title: "Create New Blog Post", categories, tags, currentPath: req.path, user: req.session.user, frontendUrl, backendUrl, getFullImageUrl, // Truyền helper function vào template }); } catch (err) { console.error("Blog create form error:", err); req.flash("error_msg", "Error loading create form"); res.redirect("/admin/blog"); } }; // Store new blog exports.store = async (req, res) => { try { const { title, excerpt, content, category, tags, status, isFeatured, author, galleryImages, quote, contentAfterQuote, } = req.body; // Generate slug const slug = generateSlug(title); // Check if slug exists const existingBlog = await Blog.findOne({ slug }); if (existingBlog) { req.flash("error_msg", "A blog post with this title already exists"); return res.redirect("/admin/blog/create"); } // Create blog data const blogData = { title, slug, excerpt, content, category: category ? Array.isArray(category) ? category : [category] : [], // Array categories tags: tags ? (Array.isArray(tags) ? tags : [tags]) : [], status: status || "published", isFeatured: isFeatured === "on", author: author || "Admin", galleryImages: galleryImages ? Array.isArray(galleryImages) ? galleryImages : [galleryImages] : [], quote: quote || "", contentAfterQuote: contentAfterQuote || "", }; // Handle featured image - using featuredImageUrl from form (uploaded via AJAX) if (req.body.featuredImageUrl) { blogData.featuredImage = req.body.featuredImageUrl; } // Create blog const blog = new Blog(blogData); await blog.save(); // AUDIT LOGGING - Blog Created await writeAuditLog({ model: "Blog", documentId: blog._id, action: AUDIT_ACTIONS.CREATE_BLOG, before: null, // No before state for CREATE after: JSON.parse(JSON.stringify(blog.toObject())), changes: [], // No changes for CREATE req, }); // Update counts await updateCategoryPostCounts(); await updateTagPostCounts(); await RecentPost.syncFromBlogs(); req.flash("success_msg", "Blog post created successfully"); res.redirect("/admin/blog"); } catch (err) { console.error("Blog store error:", err); req.flash("error_msg", "Error creating blog post"); res.redirect("/admin/blog/create"); } }; // Show edit blog form exports.edit = async (req, res) => { try { const blog = await Blog.findById(req.params.id); if (!blog) { req.flash("error_msg", "Blog post not found"); return res.redirect("/admin/blog"); } const categories = await BlogCategory.getActive(); const tags = await BlogTag.getActive(); // Get all comments for this blog post (including pending, approved, rejected) const allComments = await BlogComment.find({ postId: blog._id }) .sort({ createdAt: -1 }) .lean(); // Organize comments with replies const parentComments = allComments.filter((c) => !c.parentId); const commentsWithReplies = parentComments.map((parent) => { const replies = allComments.filter( (c) => c.parentId && c.parentId.toString() === parent._id.toString(), ); return { ...parent, replies: replies, }; }); const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const backendUrl = process.env.BACKEND_URL || "http://localhost:3001"; res.render("admin/blog/edit", { layout: "layouts/main", title: "Edit Blog Post", blog, categories, tags, comments: commentsWithReplies, commentsCount: allComments.length, currentPath: req.path, user: req.session.user, frontendUrl, backendUrl, getFullImageUrl, // Truyền helper function vào template }); } catch (err) { console.error("Blog edit form error:", err); req.flash("error_msg", "Error loading blog post"); res.redirect("/admin/blog"); } }; // Update blog exports.update = async (req, res) => { try { const blog = await Blog.findById(req.params.id); if (!blog) { req.flash("error_msg", "Blog post not found"); return res.redirect("/admin/blog"); } // Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(blog.toObject())); const { title, excerpt, content, category, tags, status, isFeatured, author, galleryImages, quote, contentAfterQuote, } = req.body; // Update blog data blog.title = title; blog.excerpt = excerpt; blog.content = content; blog.category = category ? Array.isArray(category) ? category : [category] : []; // Array categories blog.tags = tags ? (Array.isArray(tags) ? tags : [tags]) : []; blog.status = status || "published"; blog.isFeatured = isFeatured === "on"; blog.author = author || "Admin"; blog.galleryImages = galleryImages ? Array.isArray(galleryImages) ? galleryImages : [galleryImages] : []; blog.quote = quote || ""; blog.contentAfterQuote = contentAfterQuote || ""; // Handle featured image - using featuredImageUrl from form (uploaded via AJAX) if (req.body.featuredImageUrl) { blog.featuredImage = req.body.featuredImageUrl; } // Generate new slug if title changed const newSlug = generateSlug(title); if (newSlug !== blog.slug) { const existingBlog = await Blog.findOne({ slug: newSlug, _id: { $ne: blog._id }, }); if (existingBlog) { req.flash("error_msg", "A blog post with this title already exists"); return res.redirect(`/admin/blog/${blog._id}/edit`); } blog.slug = newSlug; } await blog.save(); // Capture AFTER state const afterData = JSON.parse(JSON.stringify(blog.toObject())); // AUDIT LOGGING - Blog Updated const changes = diffObject(beforeData, afterData); if (changes.length > 0) { await writeAuditLog({ model: "Blog", documentId: blog._id, action: AUDIT_ACTIONS.UPDATE_BLOG, before: beforeData, after: afterData, changes, req, }); } // Update counts await updateCategoryPostCounts(); await updateTagPostCounts(); await RecentPost.syncFromBlogs(); req.flash("success_msg", "Blog post updated successfully"); res.redirect("/admin/blog"); } catch (err) { console.error("Blog update error:", err); req.flash("error_msg", "Error updating blog post"); res.redirect(`/admin/blog/${req.params.id}/edit`); } }; // Delete blog exports.destroy = async (req, res) => { try { const blog = await Blog.findById(req.params.id); if (!blog) { req.flash("error_msg", "Blog post not found"); return res.redirect("/admin/blog"); } // ✅ Capture BEFORE state const beforeData = JSON.parse(JSON.stringify(blog.toObject())); await Blog.findByIdAndDelete(req.params.id); // ✅ AUDIT LOGGING - Blog Deleted await writeAuditLog({ model: "Blog", documentId: req.params.id, action: AUDIT_ACTIONS.DELETE_BLOG, before: beforeData, after: null, // No after state for DELETE changes: [], req, }); // Update counts await updateCategoryPostCounts(); await updateTagPostCounts(); await RecentPost.syncFromBlogs(); req.flash("success_msg", "Blog post deleted successfully"); res.redirect("/admin/blog"); } catch (err) { console.error("Blog delete error:", err); req.flash("error_msg", "Error deleting blog post"); res.redirect("/admin/blog"); } }; // -------------------- Public API Controllers -------------------- // Get all published blogs for frontend exports.api = async (req, res) => { try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const skip = (page - 1) * limit; // Build filter const filter = { status: "published" }; if (req.query.category) { filter.category = { $in: [req.query.category] }; // Tìm trong array categories } if (req.query.tag) { filter.tags = { $in: [req.query.tag] }; // Tìm trong array tags } if (req.query.search) { filter.$or = [ { title: { $regex: req.query.search, $options: "i" } }, { excerpt: { $regex: req.query.search, $options: "i" } }, ]; } // Get blogs const blogs = await Blog.find(filter) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) .lean(); const totalBlogs = await Blog.countDocuments(filter); // Add base URL to images const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; const processedBlogs = blogs.map((blog) => addBaseUrlToImages(blog, baseUrl), ); res.json({ success: true, message: "Blogs fetched successfully", data: { blogs: processedBlogs, pagination: { current: page, total: Math.ceil(totalBlogs / limit), limit, totalItems: totalBlogs, }, }, }); } catch (err) { console.error("Blog API error:", err); res.status(500).json({ success: false, message: "Error loading blogs", error: err.message || "Error loading blogs", }); } }; // Get single blog by slug exports.apiShow = async (req, res) => { try { const blog = await Blog.findOne({ slug: req.params.slug, status: "published", }).lean(); if (!blog) { return res.status(404).json({ success: false, message: "Blog post not found", }); } // Get comments for this post (parent comments only) const parentComments = await BlogComment.getApprovedByPost(blog._id); // Get replies for each parent comment const commentsWithReplies = await Promise.all( parentComments.map(async (parentComment) => { const replies = await BlogComment.getReplies(parentComment._id); return { ...parentComment.toObject(), replies: replies.map((reply) => reply.toObject()), }; }), ); // Flatten comments array (parent + replies) const allComments = commentsWithReplies.flatMap((comment) => [ comment, ...comment.replies, ]); // Add comments to blog blog.comments = allComments; // Keep commentsCount in sync for frontend blog.commentsCount = allComments.length; // Add base URL to images const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; const processedBlog = addBaseUrlToImages(blog, baseUrl); res.json({ success: true, message: "Blog post fetched successfully", data: processedBlog, }); } catch (err) { console.error("Blog show API error:", err); res.status(500).json({ success: false, message: "Error loading blog post", error: err.message || "Error loading blog post", }); } }; // Create a comment (no moderation for now: default approved) exports.apiCreateComment = async (req, res) => { try { const { authorName, authorEmail, authorPhone, authorAddress, authorDate, content, parentId, } = req.body || {}; if (!authorName || !String(authorName).trim()) { return res.status(400).json({ success: false, message: "authorName is required", }); } if (!content || !String(content).trim()) { return res.status(400).json({ success: false, message: "content is required", }); } const blog = await Blog.findOne({ slug: req.params.slug, status: "published", }).lean(); if (!blog) { return res.status(404).json({ success: false, message: "Blog post not found", }); } // If replying, ensure parent exists and belongs to same post let parentObjectId = null; if (parentId) { const parent = await BlogComment.findOne({ _id: parentId, postId: blog._id, }).lean(); if (!parent) { return res.status(400).json({ success: false, message: "Invalid parentId", }); } parentObjectId = parentId; } const newComment = await BlogComment.create({ postId: blog._id, authorName: String(authorName).trim(), ...(authorEmail ? { authorEmail: String(authorEmail).trim() } : {}), ...(authorPhone ? { authorPhone: String(authorPhone).trim() } : {}), ...(authorAddress ? { authorAddress: String(authorAddress).trim() } : {}), ...(authorDate ? { authorDate: String(authorDate).trim() } : {}), content: String(content).trim(), parentId: parentObjectId, status: "approved", }); // Keep counter roughly correct (also counts replies) await Blog.updateOne({ _id: blog._id }, { $inc: { commentsCount: 1 } }); return res.json({ success: true, message: "Comment created successfully", data: newComment.toJSON(), }); } catch (err) { console.error("Create comment API error:", err); return res.status(500).json({ success: false, message: "Error creating comment", error: err.message || "Error creating comment", }); } }; // Get featured blogs exports.apiFeatured = async (req, res) => { try { const limit = parseInt(req.query.limit) || 3; const blogs = await Blog.getFeatured().limit(limit).lean(); // Add base URL to images const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("port")}`; const processedBlogs = blogs.map((blog) => addBaseUrlToImages(blog, baseUrl), ); res.json({ success: true, message: "Featured blogs fetched successfully", data: processedBlogs, }); } catch (err) { console.error("Featured blogs API error:", err); res.status(500).json({ success: false, message: "Error loading featured blogs", error: err.message || "Error loading featured blogs", }); } }; // Get recent blogs exports.apiRecent = async (req, res) => { try { const limit = parseInt(req.query.limit) || 5; // Try to get from RecentPost first let recentPosts = await RecentPost.getRecent(limit); // If no recent posts, sync from blogs if (recentPosts.length === 0) { await RecentPost.syncFromBlogs(limit); recentPosts = await RecentPost.getRecent(limit); } // Add base URL to images const baseUrl = process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`; const processedPosts = recentPosts.map((post) => addBaseUrlToImages(post, baseUrl), ); res.json({ success: true, message: "Recent blogs fetched successfully", data: processedPosts, }); } catch (err) { console.error("Recent blogs API error:", err); res.status(500).json({ success: false, message: "Error loading recent blogs", error: err.message || "Error loading recent blogs", }); } }; // Get categories of a specific blog post exports.apiCategories = async (req, res) => { try { const mongoose = require("mongoose"); let query; // Check if it's a valid ObjectId if (mongoose.Types.ObjectId.isValid(req.params.id)) { query = { _id: req.params.id }; } else { query = { slug: req.params.id }; } query.status = "published"; const blog = await Blog.findOne(query).lean(); if (!blog) { return res.status(404).json({ success: false, message: "Blog post not found", }); } // Get category details const BlogCategory = require("../models/blogCategory"); const categories = await BlogCategory.find({ name: { $in: blog.category }, isActive: true, }).lean(); res.json({ success: true, message: "Blog categories fetched successfully", data: categories, }); } catch (err) { console.error("Blog categories API error:", err); res.status(500).json({ success: false, message: "Error loading blog categories", error: err.message || "Error loading blog categories", }); } }; // Get tags of a specific blog post exports.apiTags = async (req, res) => { try { const mongoose = require("mongoose"); let query; // Check if it's a valid ObjectId if (mongoose.Types.ObjectId.isValid(req.params.id)) { query = { _id: req.params.id }; } else { query = { slug: req.params.id }; } query.status = "published"; const blog = await Blog.findOne(query).lean(); if (!blog) { return res.status(404).json({ success: false, message: "Blog post not found", }); } // Get tag details const BlogTag = require("../models/blogTag"); const tags = await BlogTag.find({ name: { $in: blog.tags }, isActive: true, }).lean(); res.json({ success: true, message: "Blog tags fetched successfully", data: tags, }); } catch (err) { console.error("Blog tags API error:", err); res.status(500).json({ success: false, message: "Error loading blog tags", error: err.message || "Error loading blog tags", }); } }; // -------------------- Comment Management Controllers -------------------- // Approve a comment exports.approveComment = async (req, res) => { try { const { blogId, commentId } = req.params; const blog = await Blog.findById(blogId); if (!blog) { return res.status(404).json({ success: false, message: "Blog post not found", }); } const comment = await BlogComment.findById(commentId); if (!comment || comment.postId.toString() !== blogId) { return res.status(404).json({ success: false, message: "Comment not found", }); } comment.status = "approved"; await comment.save(); res.json({ success: true, message: "Comment approved successfully", }); } catch (err) { console.error("Approve comment error:", err); res.status(500).json({ success: false, message: "Error approving comment", error: err.message || "Error approving comment", }); } }; // Reject a comment exports.rejectComment = async (req, res) => { try { const { blogId, commentId } = req.params; const blog = await Blog.findById(blogId); if (!blog) { return res.status(404).json({ success: false, message: "Blog post not found", }); } const comment = await BlogComment.findById(commentId); if (!comment || comment.postId.toString() !== blogId) { return res.status(404).json({ success: false, message: "Comment not found", }); } comment.status = "rejected"; await comment.save(); res.json({ success: true, message: "Comment rejected successfully", }); } catch (err) { console.error("Reject comment error:", err); res.status(500).json({ success: false, message: "Error rejecting comment", error: err.message || "Error rejecting comment", }); } }; // Delete a comment exports.deleteComment = async (req, res) => { try { const { blogId, commentId } = req.params; const blog = await Blog.findById(blogId); if (!blog) { return res.status(404).json({ success: false, message: "Blog post not found", }); } const comment = await BlogComment.findById(commentId); if (!comment || comment.postId.toString() !== blogId) { return res.status(404).json({ success: false, message: "Comment not found", }); } // Delete the comment and all its replies await BlogComment.deleteMany({ $or: [{ _id: commentId }, { parentId: commentId }], }); // Update blog comment count const remainingComments = await BlogComment.countDocuments({ postId: blogId, }); await Blog.updateOne({ _id: blogId }, { commentsCount: remainingComments }); res.json({ success: true, message: "Comment deleted successfully", }); } catch (err) { console.error("Delete comment error:", err); res.status(500).json({ success: false, message: "Error deleting comment", error: err.message || "Error deleting comment", }); } }; module.exports = exports;