Refactor giai đoạn 1: test các tính năng vừa thay đổi như tour, scene...
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Tour = require('../models/Tour');
|
||||
const Scene = require('../models/Scene');
|
||||
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
||||
const { propagateScenePrivacy } = require('../utils/sceneHelper');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// @route POST /api/tours
|
||||
// @desc Tạo một Tour mới (bước đầu tiên trước khi upload ảnh)
|
||||
// @access Private
|
||||
router.post('/', protect, async (req, res) => {
|
||||
try {
|
||||
const { name, description, lat, lng, privacy } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ message: 'Tên Tour là bắt buộc.' });
|
||||
}
|
||||
|
||||
const newTour = new Tour({
|
||||
name,
|
||||
description,
|
||||
location: { lat: Number(lat) || 0, lng: Number(lng) || 0 },
|
||||
createdBy: req.user._id,
|
||||
privacy: privacy || 'private',
|
||||
scenes: [],
|
||||
shareToken: (privacy === 'shared') ? crypto.randomBytes(24).toString('hex') : undefined
|
||||
});
|
||||
|
||||
if (newTour.privacy === 'shared') {
|
||||
// Thiết lập hạn mặc định 7 ngày nếu không chỉ định
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7);
|
||||
newTour.shareTokenExpires = expires;
|
||||
}
|
||||
|
||||
await newTour.save();
|
||||
res.status(201).json({ message: 'Tour đã được tạo thành công.', tour: newTour });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/tours/:id
|
||||
// @desc Cập nhật Tour và lan truyền quyền riêng tư xuống các cảnh con
|
||||
// @access Private (Chủ sở hữu hoặc Admin)
|
||||
router.put('/:id', protect, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
lat,
|
||||
lng,
|
||||
privacy,
|
||||
sharedWithUsers,
|
||||
sharedEmails,
|
||||
shareExpireDays
|
||||
} = req.body;
|
||||
|
||||
const tour = await Tour.findById(req.params.id);
|
||||
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' });
|
||||
|
||||
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Bạn không có quyền chỉnh sửa Tour này.' });
|
||||
}
|
||||
|
||||
tour.name = name || tour.name;
|
||||
tour.description = description !== undefined ? description : tour.description;
|
||||
if (lat !== undefined) tour.location.lat = Number(lat);
|
||||
if (lng !== undefined) tour.location.lng = Number(lng);
|
||||
|
||||
if (privacy) tour.privacy = privacy;
|
||||
|
||||
// Xử lý logic Token cho chế độ 'shared' (Link-based)
|
||||
if (tour.privacy === 'shared') {
|
||||
if (!tour.shareToken) tour.shareToken = crypto.randomBytes(24).toString('hex');
|
||||
if (shareExpireDays && shareExpireDays !== 'never') {
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + parseInt(shareExpireDays));
|
||||
tour.shareTokenExpires = expires;
|
||||
} else if (shareExpireDays === 'never') {
|
||||
tour.shareTokenExpires = null;
|
||||
}
|
||||
} else {
|
||||
tour.shareToken = undefined;
|
||||
tour.shareTokenExpires = undefined;
|
||||
}
|
||||
|
||||
// Cập nhật danh sách thành viên được chia sẻ
|
||||
if (tour.privacy === 'member' || tour.privacy === 'shared') {
|
||||
if (sharedWithUsers) {
|
||||
try { tour.sharedWith = JSON.parse(sharedWithUsers); } catch (e) { }
|
||||
}
|
||||
if (sharedEmails) {
|
||||
try { tour.sharedEmails = JSON.parse(sharedEmails); } catch (e) { }
|
||||
}
|
||||
} else if (tour.privacy === 'private') {
|
||||
tour.sharedWith = [];
|
||||
tour.sharedEmails = [];
|
||||
}
|
||||
|
||||
await tour.save();
|
||||
|
||||
// [CORE LOGIC] Lan truyền thiết lập mới xuống toàn bộ các Scene con trong Tour
|
||||
await propagateScenePrivacy(tour._id, {
|
||||
privacy: tour.privacy,
|
||||
shareToken: tour.shareToken,
|
||||
shareTokenExpires: tour.shareTokenExpires,
|
||||
sharedWith: tour.sharedWith,
|
||||
sharedEmails: tour.sharedEmails
|
||||
}, req.user._id);
|
||||
|
||||
res.json({ message: 'Tour đã được cập nhật thành công.', tour });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/tours/:id
|
||||
// @desc Lấy chi tiết Tour và danh sách các cảnh (Kiểm tra quyền truy cập)
|
||||
// @access Public (Xác thực thông qua Privacy/Token)
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const tour = await Tour.findById(req.params.id)
|
||||
.populate('createdBy', 'username')
|
||||
.populate({
|
||||
path: 'rootSceneId',
|
||||
select: 'assetId', // Chỉ lấy assetId của rootScene
|
||||
populate: { path: 'assetId', select: '_id' } // Populate assetId để lấy _id của Asset
|
||||
})
|
||||
.populate({
|
||||
path: 'scenes',
|
||||
select: 'name description assetId gps status privacy shareToken shareTokenExpires sharedWith sharedEmails createdBy',
|
||||
populate: { path: 'assetId', select: '_id' }
|
||||
})
|
||||
.lean();
|
||||
|
||||
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' });
|
||||
|
||||
const isOwner = req.user && tour.createdBy._id.toString() === req.user._id.toString();
|
||||
const isAdmin = req.user && req.user.role === 'admin';
|
||||
const isTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
|
||||
const userEmail = req.user ? req.user.email : null;
|
||||
|
||||
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
|
||||
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid) ||
|
||||
(tour.privacy === 'member' && req.user && (
|
||||
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
|
||||
(userEmail && tour.sharedEmails.includes(userEmail))
|
||||
));
|
||||
|
||||
if (!hasAccess) return res.status(403).json({ message: 'Bạn không có quyền truy cập Tour này.' });
|
||||
|
||||
res.json(tour);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/tours
|
||||
// @desc Lấy danh sách Tour công khai hoặc của chính mình
|
||||
// @access Public/Private
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
let query = { privacy: 'public' };
|
||||
|
||||
if (req.user && req.user.role !== 'guest') {
|
||||
query = {
|
||||
$or: [
|
||||
{ privacy: 'public' },
|
||||
{ createdBy: req.user._id },
|
||||
{ privacy: 'member', sharedWith: req.user._id },
|
||||
{ privacy: 'member', sharedEmails: req.user.email }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const tours = await Tour.find(query)
|
||||
.populate('createdBy', 'username')
|
||||
.populate({
|
||||
path: 'rootSceneId',
|
||||
select: 'assetId', // Chỉ lấy assetId của rootScene
|
||||
populate: { path: 'assetId', select: '_id' } // Populate assetId để lấy _id của Asset
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
res.json(tours);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/tours/:id
|
||||
// @desc Xóa Tour và xóa dây chuyền toàn bộ Scene/Asset bên trong
|
||||
// @access Private (Chủ sở hữu hoặc Admin)
|
||||
router.delete('/:id', protect, async (req, res) => {
|
||||
try {
|
||||
const tour = await Tour.findById(req.params.id);
|
||||
if (!tour) return res.status(404).json({ message: 'Tour không tồn tại.' });
|
||||
|
||||
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Bạn không có quyền xóa Tour này.' });
|
||||
}
|
||||
|
||||
const { deleteSceneCascade } = require('../utils/sceneHelper');
|
||||
const scenesInTour = await Scene.find({ tourId: tour._id });
|
||||
|
||||
for (const scene of scenesInTour) {
|
||||
await deleteSceneCascade(scene._id, req.user._id);
|
||||
}
|
||||
|
||||
await Tour.findByIdAndDelete(req.params.id);
|
||||
res.json({ message: `Tour "${tour.name}" đã được xóa thành công.` });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Tính toán và cập nhật tọa độ trung tâm (location) của Tour
|
||||
* dựa trên giá trị trung bình tọa độ GPS của tất cả các cảnh con hiện có.
|
||||
* @param {string} tourId - ID của Tour cần cập nhật
|
||||
*/
|
||||
const updateTourCenter = async (tourId) => {
|
||||
try {
|
||||
const scenes = await Scene.find({ tourId }).select('gps');
|
||||
|
||||
if (!scenes || scenes.length === 0) return;
|
||||
|
||||
let totalLat = 0;
|
||||
let totalLng = 0;
|
||||
let validCount = 0;
|
||||
|
||||
scenes.forEach(scene => {
|
||||
// Chỉ tính toán dựa trên các cảnh có tọa độ GPS hợp lệ (khác 0,0)
|
||||
if (scene.gps &&
|
||||
typeof scene.gps.lat === 'number' &&
|
||||
typeof scene.gps.lng === 'number' &&
|
||||
(scene.gps.lat !== 0 || scene.gps.lng !== 0)) {
|
||||
|
||||
totalLat += scene.gps.lat;
|
||||
totalLng += scene.gps.lng;
|
||||
validCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (validCount > 0) {
|
||||
await Tour.findByIdAndUpdate(tourId, {
|
||||
location: {
|
||||
lat: totalLat / validCount,
|
||||
lng: totalLng / validCount
|
||||
}
|
||||
}, {
|
||||
// Thay thế cho 'new: true' để lấy dữ liệu sau khi cập nhật
|
||||
returnDocument: 'after'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[TourController] Error updating center for tour ${tourId}:`, error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/tours/recalculate-all
|
||||
// @desc Admin: Tính toán lại trung tâm cho toàn bộ Tour trong hệ thống
|
||||
// @access Private (Admin only)
|
||||
router.post('/recalculate-all', protect, async (req, res) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Tính năng này chỉ dành cho Quản trị viên.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const tours = await Tour.find({});
|
||||
let processedCount = 0;
|
||||
|
||||
// Thực hiện tuần tự để tránh gây áp lực quá lớn lên cơ sở dữ liệu
|
||||
for (const tour of tours) {
|
||||
await updateTourCenter(tour._id);
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `Đã hoàn thành tính toán lại trung tâm cho ${processedCount} Tour.`,
|
||||
processedCount
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.updateTourCenter = updateTourCenter;
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user