Files
3dtours/backend/middlewares/TourController.js
T

329 lines
12 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 tourCreatedById = tour.createdBy?._id || tour.createdBy;
const isOwner = req.user && req.user._id && tourCreatedById && tourCreatedById.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;
const token = req.query.token;
// [Security] Check permissions based on tour privacy
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin;
// Shared link access - check token validity
if (!hasAccess && tour.privacy === 'shared' && token && isTokenValid) {
hasAccess = token === tour.shareToken;
}
// Member access - check if user is in sharedWith or sharedEmails
if (!hasAccess && tour.privacy === 'member' && req.user && req.user._id && req.user.role !== 'guest') {
hasAccess = true;
}
// Fallback for specific shared members
if (!hasAccess && tour.privacy === 'member' && req.user && req.user._id) {
hasAccess = tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.some(email => email.toLowerCase() === userEmail.toLowerCase()));
}
// Private tours - only owner and admin
// (hasAccess already set above if owner/admin)
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, member, shared (với token), hoặc của chính mình
// @access Public/Private
router.get('/', optionalAuth, async (req, res) => {
try {
const { token } = req.query;
let query = { privacy: 'public' };
if (req.user && req.user.role !== 'guest') {
query = {
$or: [
{ privacy: 'public' },
{ privacy: 'member' },
{ createdBy: req.user._id },
{ privacy: 'member', sharedWith: req.user._id },
{ privacy: 'member', sharedEmails: req.user.email }
]
};
}
// [Task 4.1] Support shared tours via token for guests
if (token) {
const tourWithToken = await Tour.findOne({
shareToken: token,
privacy: 'shared',
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
}).select('_id');
if (tourWithToken) {
query = {
$or: [
query,
{ _id: tourWithToken._id }
]
};
}
}
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;