Files
cms.uldp.edu.vn/controllers/blogController.js
2026-04-11 19:15:01 +07:00

902 lines
24 KiB
JavaScript

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;