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 === "
" || 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"); } };