292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
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; |