forked from UKSOURCE/cms.hailearning.edu.vn
291 lines
8.7 KiB
JavaScript
291 lines
8.7 KiB
JavaScript
const Travel = require("../models/travel");
|
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
|
const fs = require("fs").promises;
|
|
const path = require("path");
|
|
const writeAuditLog = require("../audit/writeAuditLog");
|
|
const diffObject = require("../audit/diffObject");
|
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
|
|
|
/**
|
|
* Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
|
|
*/
|
|
function extractYouTubeId(url) {
|
|
if (!url || typeof url !== "string") return null;
|
|
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
|
|
const regex =
|
|
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
|
|
const match = url.match(regex);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Hàm Helper: Làm sạch danh sách blocks của Editor.js
|
|
* Loại bỏ duplicate video (vừa embed vừa text link) và paragraph rỗng
|
|
*/
|
|
function sanitizeContentBlocks(blocks) {
|
|
if (!blocks || !Array.isArray(blocks)) return [];
|
|
|
|
const seenVideoIds = new Set();
|
|
|
|
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
|
|
const processedBlocks = blocks.map((block) => {
|
|
if (block.type === "embed") {
|
|
const url = block.data.source || block.data.embed || "";
|
|
const videoId = extractYouTubeId(url);
|
|
if (videoId) {
|
|
seenVideoIds.add(videoId);
|
|
// Cập nhật lại data chuẩn cho Editor.js
|
|
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
|
|
block.data.source = url;
|
|
block.data.videoId = videoId;
|
|
block.data.service = "youtube";
|
|
}
|
|
}
|
|
return block;
|
|
});
|
|
|
|
// Bước 2: Lọc bỏ paragraph rác
|
|
return processedBlocks.filter((block) => {
|
|
if (block.type === "paragraph") {
|
|
const text = (block.data?.text || "").trim();
|
|
|
|
// Xóa paragraph rỗng
|
|
if (text === "" || text === "<br>" || text === " ") return false;
|
|
|
|
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
|
|
const videoIdInText = extractYouTubeId(text);
|
|
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
|
|
console.log(
|
|
`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// GET: Show travel editor
|
|
exports.index = async (req, res) => {
|
|
try {
|
|
const travel = await Travel.findOne();
|
|
|
|
if (!travel) {
|
|
return res.render("admin/travel/index", {
|
|
title: "Travel Management",
|
|
data: {
|
|
page: {
|
|
title: "Travel Information",
|
|
description: "",
|
|
metadata: { title: "", description: "" },
|
|
},
|
|
hero: { title: "Travel Information", backgroundImage: "" },
|
|
content: { blocks: [] },
|
|
enableScrollspy: false,
|
|
},
|
|
message: "No travel data found. Please run migration first.",
|
|
});
|
|
}
|
|
|
|
res.render("admin/travel/index", {
|
|
title: "Travel Management",
|
|
data: travel,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error loading travel page:", error);
|
|
res.status(500).send("Error loading travel page");
|
|
}
|
|
};
|
|
|
|
// POST: Update travel information
|
|
exports.update = async (req, res) => {
|
|
try {
|
|
const { page, hero, content, enableScrollspy } = req.body;
|
|
|
|
// Get current data for before state
|
|
const currentTravel = await Travel.findOne();
|
|
|
|
// ✅ Capture BEFORE state
|
|
const beforeData = currentTravel
|
|
? JSON.parse(
|
|
JSON.stringify(
|
|
currentTravel.toObject ? currentTravel.toObject() : currentTravel,
|
|
),
|
|
)
|
|
: {};
|
|
|
|
const updateData = {};
|
|
|
|
if (page) updateData.page = JSON.parse(page);
|
|
if (hero) updateData.hero = JSON.parse(hero);
|
|
|
|
if (content) {
|
|
let contentObj = JSON.parse(content);
|
|
// Áp dụng bộ lọc dọn dẹp nội dung
|
|
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
|
updateData.content = contentObj;
|
|
}
|
|
|
|
if (enableScrollspy !== undefined) {
|
|
updateData.enableScrollspy =
|
|
enableScrollspy === "true" || enableScrollspy === true;
|
|
}
|
|
|
|
const updatedTravel = await Travel.findOneAndUpdate({}, updateData, {
|
|
upsert: true,
|
|
new: true,
|
|
});
|
|
|
|
// ✅ Capture AFTER state
|
|
const afterData = JSON.parse(
|
|
JSON.stringify(
|
|
updatedTravel.toObject ? updatedTravel.toObject() : updatedTravel,
|
|
),
|
|
);
|
|
|
|
// ✅ AUDIT LOGGING - Travel Updated
|
|
const changes = diffObject(beforeData, afterData);
|
|
if (changes.length > 0) {
|
|
await writeAuditLog({
|
|
model: "Travel",
|
|
documentId: updatedTravel._id,
|
|
action: AUDIT_ACTIONS.UPDATE_TRAVEL,
|
|
before: beforeData,
|
|
after: afterData,
|
|
changes,
|
|
req,
|
|
});
|
|
}
|
|
|
|
req.flash(
|
|
"success",
|
|
"Travel information updated and sanitized successfully",
|
|
);
|
|
res.redirect("/admin/travel");
|
|
} catch (error) {
|
|
console.error("Error updating travel:", error);
|
|
req.flash("error", "Error updating travel information");
|
|
res.redirect("/admin/travel");
|
|
}
|
|
};
|
|
|
|
// GET: Travel data API (Sử dụng cho Frontend/Public)
|
|
exports.api = exports.getTravelData = async (req, res) => {
|
|
try {
|
|
const travel = await Travel.findOne();
|
|
if (!travel) {
|
|
return res.status(404).json({ error: "Travel data not found" });
|
|
}
|
|
|
|
const travelObj = travel.toObject();
|
|
const baseUrl =
|
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
|
const processed = addBaseUrlToImages(travelObj, baseUrl);
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
hero: processed.hero,
|
|
page: processed.page,
|
|
content: processed.content,
|
|
enableScrollspy: processed.enableScrollspy,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching travel data:", error);
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
};
|
|
|
|
// POST: Preview travel
|
|
exports.preview = async (req, res) => {
|
|
try {
|
|
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } =
|
|
req.body;
|
|
|
|
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
|
|
let contentObj = JSON.parse(content);
|
|
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
|
|
|
const previewData = {
|
|
page: {
|
|
title: pageTitle || "Travel Information",
|
|
year: pageYear || "",
|
|
},
|
|
hero: {
|
|
title: heroTitle || "Travel Information",
|
|
backgroundImage: heroBackgroundImage || "",
|
|
},
|
|
content: contentObj,
|
|
enableScrollspy: false,
|
|
};
|
|
|
|
res.render("page/travel", {
|
|
title: "Travel Preview",
|
|
data: previewData,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error generating preview:", error);
|
|
res.status(500).send("Error generating preview");
|
|
}
|
|
};
|
|
|
|
// GET: Seed/Import from JSON
|
|
exports.seed = async (req, res) => {
|
|
try {
|
|
const jsonPath = path.join(__dirname, "../data/travel.json");
|
|
const jsonData = await fs.readFile(jsonPath, "utf-8");
|
|
const jsonTravelData = JSON.parse(jsonData);
|
|
|
|
let contentBlocks = [];
|
|
|
|
// Trường hợp JSON đã có định dạng bài viết (blog format)
|
|
if (
|
|
Array.isArray(jsonTravelData.posts) &&
|
|
jsonTravelData.posts.length > 0
|
|
) {
|
|
const firstPost = jsonTravelData.posts[0];
|
|
contentBlocks =
|
|
firstPost.content && firstPost.content.blocks
|
|
? firstPost.content.blocks
|
|
: [];
|
|
}
|
|
// Trường hợp format cũ (legacy)
|
|
else {
|
|
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
|
|
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
|
|
}
|
|
|
|
// Luôn làm sạch dữ liệu trước khi seed vào DB
|
|
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
|
|
|
|
const travelData = {
|
|
page: {
|
|
title:
|
|
jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
|
|
year: jsonTravelData.page?.year || "",
|
|
metadata: {
|
|
title: "Travel Guide - Go and Grow Camp",
|
|
description:
|
|
"Everything you need to know about traveling to our camps",
|
|
},
|
|
},
|
|
hero: {
|
|
title: jsonTravelData.hero?.title || "Travel Information",
|
|
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
|
|
},
|
|
content: { blocks: cleanedBlocks },
|
|
enableScrollspy: true,
|
|
};
|
|
|
|
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
|
|
|
|
req.flash("success", "Travel data seeded and sanitized successfully");
|
|
res.redirect("/admin/travel");
|
|
} catch (error) {
|
|
console.error("Error seeding travel data:", error);
|
|
req.flash("error", "Failed to seed travel data");
|
|
res.redirect("/admin/travel");
|
|
}
|
|
};
|