"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = exports.CommentGateway = void 0; const path = __importStar(require("path")); const fs = __importStar(require("fs")); const crypto = __importStar(require("crypto")); const config_1 = require("@nestjs/config"); require("reflect-metadata"); const zlib = __importStar(require("zlib")); const util_1 = require("util"); const sharp_1 = __importDefault(require("sharp")); const exifr_1 = __importDefault(require("exifr")); const heic_convert_1 = __importDefault(require("heic-convert")); const core_1 = require("@nestjs/core"); const common_1 = require("@nestjs/common"); const platform_express_1 = require("@nestjs/platform-express"); const websockets_1 = require("@nestjs/websockets"); const socket_io_1 = require("socket.io"); const prisma_service_1 = require("../prisma/prisma.service"); const client_1 = require("@prisma/client"); const bcrypt = __importStar(require("bcrypt")); const admin_guard_1 = require("./auth/admin.guard"); const nodemailer = __importStar(require("nodemailer")); const jwt_1 = require("@nestjs/jwt"); const jwt_auth_guard_1 = require("./auth/jwt-auth.guard"); const jwt_auth_guard_2 = require("./auth/jwt-auth.guard"); const jwt_strategy_1 = require("./auth/jwt.strategy"); const core_2 = require("@nestjs/core"); const common_2 = require("@nestjs/common"); const cache_manager_1 = require("@nestjs/cache-manager"); const cache_manager_redis_yet_1 = require("cache-manager-redis-yet"); const compress_cache_interceptor_1 = require("./common/compress-cache.interceptor"); const gzip = (0, util_1.promisify)(zlib.gzip); const gunzip = (0, util_1.promisify)(zlib.gunzip); const UPLOAD_ROOT = path.join(process.cwd(), 'uploads'); let CommentGateway = class CommentGateway { handleConnection(client) { console.log(`[WS] Client connected: ${client.id}`); } handleJoinTour(client, tourId) { client.join(`tour_${tourId}`); console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`); } handleJoinPhoto(client, photoId) { client.join(`photo_${photoId}`); console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`); } notifyNewComment(tourId, data) { this.server.to(`tour_${tourId}`).emit('commentAdded', data); } notifyNewPhotoComment(photoId, data) { this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data); } handleJoinUser(client, userId) { client.join(`user_${userId}`); console.log(`[WS] Client ${client.id} joined user room: user_${userId}`); } notifyNewMessage(receiverId, data) { this.server.to(`user_${receiverId}`).emit('messageReceived', data); } notifyConnectionAccepted(requesterId, data) { this.server.to(`user_${requesterId}`).emit('connectionAccepted', data); } notifyJoinRequestAccepted(userId, data) { this.server.to(`user_${userId}`).emit('joinRequestAccepted', data); } }; exports.CommentGateway = CommentGateway; __decorate([ (0, websockets_1.WebSocketServer)(), __metadata("design:type", socket_io_1.Server) ], CommentGateway.prototype, "server", void 0); __decorate([ (0, websockets_1.SubscribeMessage)('joinTour'), __metadata("design:type", Function), __metadata("design:paramtypes", [socket_io_1.Socket, String]), __metadata("design:returntype", void 0) ], CommentGateway.prototype, "handleJoinTour", null); __decorate([ (0, websockets_1.SubscribeMessage)('joinPhoto'), __metadata("design:type", Function), __metadata("design:paramtypes", [socket_io_1.Socket, String]), __metadata("design:returntype", void 0) ], CommentGateway.prototype, "handleJoinPhoto", null); __decorate([ (0, websockets_1.SubscribeMessage)('joinUser'), __metadata("design:type", Function), __metadata("design:paramtypes", [socket_io_1.Socket, String]), __metadata("design:returntype", void 0) ], CommentGateway.prototype, "handleJoinUser", null); exports.CommentGateway = CommentGateway = __decorate([ (0, websockets_1.WebSocketGateway)({ cors: { origin: '*' } }) ], CommentGateway); const CACHE_TTL = { DEFAULT: 600000, RESOURCE_TO_TOUR: 3600000, USER_ROLE: 300000, }; async function bootstrap() { const app = await core_1.NestFactory.create(AppModule); app.setGlobalPrefix('api/v1'); app.enableCors(); if (!fs.existsSync(UPLOAD_ROOT)) { fs.mkdirSync(UPLOAD_ROOT, { recursive: true }); } app.useStaticAssets(UPLOAD_ROOT, { prefix: '/uploads/', }); const prisma = app.get(prisma_service_1.PrismaService); startAutoCleanup(prisma); await app.listen(3001); console.log(`🚀 Server is running on: http://localhost:3001`); } exports.ROLES_KEY = 'roles'; const Roles = (...roles) => (0, common_2.SetMetadata)(exports.ROLES_KEY, roles); exports.Roles = Roles; let TourRoleGuard = class TourRoleGuard { constructor(reflector, prisma, cacheManager) { this.reflector = reflector; this.prisma = prisma; this.cacheManager = cacheManager; } async canActivate(context) { const requiredRoles = this.reflector.getAllAndOverride(exports.ROLES_KEY, [ context.getHandler(), context.getClass(), ]); const defaultRoles = [client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER]; const rolesToCheck = requiredRoles && requiredRoles.length > 0 ? requiredRoles : defaultRoles; const request = context.switchToHttp().getRequest(); const user = request.user; let tourId = request.params.tourId; const resourceId = request.params.id || request.params.legId || request.params.locationId; if (!tourId && resourceId) { const resCacheKey = `res-to-tour:${resourceId}`; const compressedData = await this.cacheManager.get(resCacheKey); if (compressedData) { try { const decompressed = await gunzip(compressedData); tourId = decompressed.toString(); } catch (e) { console.error('Lỗi giải nén cache:', e); } } else { const isTour = await this.prisma.tour.findUnique({ where: { id: resourceId }, select: { id: true } }); if (isTour) { tourId = resourceId; } else { const leg = await this.prisma.leg.findUnique({ where: { id: resourceId }, select: { tourId: true } }); if (leg) { tourId = leg.tourId; } else { const loc = await this.prisma.location.findUnique({ where: { id: resourceId }, include: { leg: { select: { tourId: true } } } }); if (loc) { tourId = loc.leg.tourId; } else { const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true, uploaderId: true } }); if (photo) { if (user && photo.uploaderId === user.id) return true; if (!photo.tourId) return true; tourId = photo.tourId; } } } } if (tourId) { try { const compressed = await gzip(Buffer.from(tourId)); await this.cacheManager.set(resCacheKey, compressed, CACHE_TTL.RESOURCE_TO_TOUR); } catch (e) { await this.cacheManager.set(resCacheKey, tourId, CACHE_TTL.RESOURCE_TO_TOUR); } } } } if (!user || !tourId) { if (!resourceId && !request.params.tourId) return true; return false; } const roleCacheKey = `user-role:${user.id}:${tourId}`; let role = await this.cacheManager.get(roleCacheKey); if (!role) { const participation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: user.id } }, }); if (!participation) return false; role = participation.role; await this.cacheManager.set(roleCacheKey, role, CACHE_TTL.USER_ROLE); } if (!rolesToCheck.some(r => role === r)) { throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này trong tour này.'); } if (tourId) { request.tourId = tourId; } return true; } }; exports.TourRoleGuard = TourRoleGuard; exports.TourRoleGuard = TourRoleGuard = __decorate([ (0, common_1.Injectable)(), __param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)), __metadata("design:paramtypes", [core_2.Reflector, prisma_service_1.PrismaService, Object]) ], TourRoleGuard); let EmailService = class EmailService { constructor() { const user = process.env.SMTP_USER; const pass = process.env.SMTP_PASS; if (!user || !pass) { console.warn('[EmailService] ⚠️ SMTP_USER hoặc SMTP_PASS chưa được cấu hình trong file .env. Tính năng gửi mã OTP sẽ không khả dụng.'); } else { this.transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST || 'smtp.gmail.com', port: parseInt(process.env.SMTP_PORT || '587'), secure: process.env.SMTP_SECURE === 'true', auth: { user: user, pass: pass, }, }); this.transporter.verify((error) => { if (error) { console.error('[EmailService] ❌ Lỗi kết nối SMTP:', error.message); } else { console.log('[EmailService] ✅ Kết nối SMTP thành công. Sẵn sàng gửi mail.'); } }); } } async sendOTP(email, otp) { if (!process.env.SMTP_USER || !process.env.SMTP_PASS) { throw new Error('Thiếu cấu hình SMTP_USER hoặc SMTP_PASS trong file .env'); } const mailOptions = { from: `"Travel Planning Support" <${process.env.SMTP_USER}>`, to: email, subject: 'Mã OTP Xác Thực Hệ Thống', html: `
Xin chào,
Quản trị viên hệ thống đã yêu cầu cấp và gửi mã OTP xác thực tới tài khoản của bạn. Vui lòng sử dụng mã bảo mật dưới đây:
Mã OTP này có hiệu lực trong vòng 5 phút. Vui lòng tuyệt đối không chia sẻ mã này cho bất kỳ ai khác.
Đây là email tự động từ ứng dụng hệ thống Travel Planning.
Xin chào,
Bạn đã được mời tham gia hành trình du lịch "${tourTitle}".
Vui lòng nhấp vào nút dưới đây để chấp nhận lời mời và gia nhập hành trình của chúng tôi:
Hoặc bạn có thể sao chép liên kết dưới đây và dán vào trình duyệt:
${inviteLink}
Lời mời này có hiệu lực trong vòng 7 ngày. Hãy nhanh tay đăng ký nhé!
Đây là email tự động từ ứng dụng hệ thống Travel Planning.
Nội dung ghi chú tổng quan của chuyến đi...
`; try { await this.prisma.tourNote.create({ data: { tourId: tour.id, userId: req.user.id, title: `Ghi chú: ${filteredTitle}`, content: noteContent } }); } catch (err) { console.warn(`Failed to auto-create note for tour ${tour.id}:`, err.message); } await this.cacheManager.del(`/api/v1/tours/explore`); return tour; } async addLocation(tourId, body, req) { const legId = body.legId; console.log(`[USER ACTION] User ${req.user.id} added a NEW VISIT point to Tour ${tourId}: "${body.name}"`); const leg = legId ? await this.prisma.leg.findUnique({ where: { id: legId } }) : await this.prisma.leg.findFirst({ where: { tourId } }); if (!leg) throw new common_1.NotFoundException('Không tìm thấy chặng phù hợp để thêm địa điểm'); return this.prisma.location.create({ data: { name: body.name, address: body.address, latitude: body.latitude, longitude: body.longitude, type: body.type, legId: leg.id, plannedStart: body.plannedStart ? new Date(body.plannedStart) : null, plannedEnd: body.plannedEnd ? new Date(body.plannedEnd) : null, } }).then(async (loc) => { if (body.expenseAmount && Number(body.expenseAmount) > 0) { let paidById = body.paidById || null; if (paidById) { const userExists = await this.prisma.user.findUnique({ where: { id: paidById } }); if (!userExists) { paidById = null; } } await this.prisma.expense.create({ data: { amount: Number(body.expenseAmount), category: body.expenseCategory || 'OTHER', locationId: loc.id, legId: loc.legId, description: body.expenseDescription || `Chi phí tại ${loc.name}`, note: body.expenseNote || null, paidById, } }); } await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); return loc; }); } async updateTourStartPoint(tourId, body, req) { const { latitude, longitude, name, plannedEnd } = body; console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`); await this.prisma.location.deleteMany({ where: { leg: { tourId: tourId }, plannedStart: new Date(0) } }); const firstLeg = await this.prisma.leg.findFirst({ where: { tourId }, orderBy: { sequence: 'asc' } }); if (!firstLeg) throw new common_1.NotFoundException('Không tìm thấy chặng đầu tiên cho Tour này'); return this.prisma.location.create({ data: { name: name || 'Điểm xuất phát', latitude, longitude, type: 'MOVE', legId: firstLeg.id, plannedStart: new Date(0), plannedEnd: plannedEnd ? new Date(plannedEnd) : null, } }).then(async (loc) => { await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); return loc; }); } async updateTourEndPoint(tourId, body, req) { const { latitude, longitude, name, plannedStart } = body; console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`); await this.prisma.location.deleteMany({ where: { leg: { tourId: tourId }, plannedEnd: new Date(0) } }); const lastLeg = await this.prisma.leg.findFirst({ where: { tourId }, orderBy: { sequence: 'desc' } }); if (!lastLeg) throw new common_1.NotFoundException('Không tìm thấy chặng cuối cùng cho Tour này'); return this.prisma.location.create({ data: { name: name || 'Điểm kết thúc', latitude, longitude, type: 'MOVE', legId: lastLeg.id, plannedStart: plannedStart ? new Date(plannedStart) : null, plannedEnd: new Date(0), } }).then(async (loc) => { await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); return loc; }); } async initializeLegs(tourId, body) { const { count } = body; if (count <= 0 || count > 20) throw new common_1.BadRequestException('Số lượng chặng không hợp lệ (1-20)'); const existingLegs = await this.prisma.leg.findMany({ where: { tourId }, orderBy: { sequence: 'asc' } }); const needed = count - existingLegs.length; if (needed > 0) { const createData = Array.from({ length: needed }).map((_, i) => ({ tourId, sequence: existingLegs.length + i + 1, note: `Chặng ${existingLegs.length + i + 1}` })); await this.prisma.leg.createMany({ data: createData }); } const allLegs = await this.prisma.leg.findMany({ where: { tourId }, orderBy: { sequence: 'asc' } }); const lastLeg = allLegs[allLegs.length - 1]; const endPoint = await this.prisma.location.findFirst({ where: { leg: { tourId }, plannedEnd: new Date(0) } }); if (endPoint && lastLeg && endPoint.legId !== lastLeg.id) { await this.prisma.location.update({ where: { id: endPoint.id }, data: { legId: lastLeg.id } }); } await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); return allLegs; } async addLeg(tourId, body) { const tour = await this.prisma.tour.findUnique({ where: { id: tourId }, include: { legs: true } }); if (!tour) throw new common_1.NotFoundException('Không tìm thấy tour'); return this.prisma.leg.create({ data: { tourId, sequence: tour.legs.length + 1, note: body.note || `Chặng ${tour.legs.length + 1}` } }).then(async (leg) => { await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); return leg; }); } async updateTour(id, body) { const updateData = { startDate: body.startDate ? new Date(body.startDate) : undefined, tags: body.tags, endDate: body.endDate ? new Date(body.endDate) : undefined, adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined, childCount: body.childCount !== undefined ? Number(body.childCount) : undefined, childDiscount: body.childDiscount !== undefined ? Number(body.childDiscount) : undefined, }; if (body.title !== undefined) { updateData.title = await filterText(this.prisma, body.title); } if (body.description !== undefined) { updateData.description = await filterText(this.prisma, body.description); } return this.prisma.tour.update({ where: { id }, data: updateData, }).then(async (tour) => { await Promise.all([ this.cacheManager.del(id), this.cacheManager.del(`/api/v1/tours/${id}`), this.cacheManager.del(`/api/v1/tours/${id}/public`), ]); return tour; }); } async deleteTour(id) { const tour = await this.prisma.tour.findUnique({ where: { id }, include: { participants: true, photos: true, legs: { include: { locations: true } } } }); if (!tour) throw new common_1.NotFoundException('Không tìm thấy tour'); for (const participant of tour.participants) { if (participant.userId) { await this.cacheManager.del(`user-role:${participant.userId}:${id}`); } } await this.cacheManager.del(`res-to-tour:${id}`); for (const leg of tour.legs) { await this.cacheManager.del(`res-to-tour:${leg.id}`); for (const loc of leg.locations) { await this.cacheManager.del(`res-to-tour:${loc.id}`); } } for (const photo of tour.photos) { await this.cacheManager.del(`res-to-tour:${photo.id}`); } await this.prisma.tour.update({ where: { id }, data: { isDeleted: true, deletedAt: new Date() } }); await this.prisma.photo.updateMany({ where: { tourId: id }, data: { isDeleted: true, deletedAt: new Date() } }); await this.prisma.tourNote.updateMany({ where: { tourId: id }, data: { isDeleted: true, deletedAt: new Date() } }); await this.cacheManager.del(`/api/v1/tours/explore`); await this.cacheManager.del(id); await this.cacheManager.del(`/api/v1/tours/${id}`); await this.cacheManager.del(`/api/v1/tours/${id}/public`); return { success: true }; } async getPublicTours(req) { return this.prisma.tour.findMany({ where: { isDeleted: false }, take: 50, include: { participants: { select: { userId: true, role: true, displayName: true } }, joinRequests: { where: { userId: req.user.id, status: 'PENDING' }, select: { id: true } }, photos: { take: 1 }, legs: { orderBy: { sequence: 'asc' }, include: { locations: { orderBy: [ { plannedStart: { sort: 'asc', nulls: 'last' } }, { createdAt: 'asc' } ], include: { _count: { select: { comments: true } } } } } } } }); } async getTourDetails(id) { const tour = await this.prisma.tour.findUnique({ where: { id }, include: { participants: { include: { user: { select: { id: true, name: true, email: true } } } }, photos: { where: { isDeleted: false }, include: { uploader: { select: { id: true, name: true } } } }, legs: { orderBy: { sequence: 'asc' }, include: { expenses: { include: { location: { select: { name: true, plannedStart: true } }, paidBy: { select: { id: true, name: true } } } }, locations: { orderBy: [ { plannedStart: { sort: 'asc', nulls: 'last' } }, { createdAt: 'asc' } ], include: { _count: { select: { comments: true } } } }, }, }, }, }); if (!tour || tour.isDeleted) throw new common_1.NotFoundException(`Không tìm thấy Tour với ID ${id}`); return tour; } async addMember(tourId, body, req) { const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY']; const role = validRoles.includes(body.role) ? body.role : 'MEMBER'; if (body.displayName) { const participation = await this.prisma.tourParticipant.create({ data: { tourId, role, displayName: body.displayName, }, include: { user: { select: { id: true, name: true, email: true } } }, }); await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); return participation; } if (!body.userId) { throw new common_1.BadRequestException('Vui lòng cung cấp userId hoặc displayName'); } await this.cacheManager.del(`user-role:${body.userId}:${tourId}`); const participation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: body.userId } }, }); if (participation) { return this.prisma.tourParticipant.update({ where: { tourId_userId: { tourId, userId: body.userId } }, data: { role }, include: { user: { select: { id: true, name: true, email: true } } }, }); } let currentRole = req.user.tourParticipation?.role; if (!currentRole) { const participation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: req.user.id } }, }); currentRole = participation?.role; } if (!currentRole || !(currentRole === 'OWNER' || currentRole === 'MANAGER')) { const joinRequest = await this.prisma.joinRequest.create({ data: { tourId, userId: body.userId, requestedById: req.user.id, status: 'PENDING', }, include: { user: { select: { id: true, name: true, email: true } }, requestedBy: { select: { id: true, name: true, email: true } }, }, }); return { ...joinRequest, pendingApproval: true }; } return this.prisma.tourParticipant.create({ data: { tourId, userId: body.userId, role, }, include: { user: { select: { id: true, name: true, email: true } } }, }); } async updateMemberCounts(tourId, userId, body, req) { const participant = await this.prisma.tourParticipant.findFirst({ where: { OR: [ { id: userId }, { tourId, userId } ] } }); if (!participant) { throw new common_1.NotFoundException('Không tìm thấy thành viên trong tour.'); } const isSelf = req.user.id === participant.userId; const requesterParticipation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: req.user.id } }, }); const canManage = requesterParticipation?.role === 'OWNER' || requesterParticipation?.role === 'MANAGER'; if (!canManage && !isSelf) { throw new common_1.ForbiddenException('Bạn không có quyền chỉnh sửa thông tin thành viên này.'); } const data = {}; if (body.adultCount !== undefined) { if (body.adultCount < 1) throw new common_1.BadRequestException('Số lượng người lớn tối thiểu là 1.'); data.adultCount = Number(body.adultCount); } if (body.childCount !== undefined) { if (body.childCount < 0) throw new common_1.BadRequestException('Số lượng trẻ em không được âm.'); data.childCount = Number(body.childCount); } if (body.role !== undefined && canManage) { const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY']; if (validRoles.includes(body.role)) { data.role = body.role; } } if (participant.userId) { await this.cacheManager.del(`user-role:${participant.userId}:${tourId}`); } await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); return this.prisma.tourParticipant.update({ where: { id: participant.id }, data, include: { user: { select: { id: true, name: true, email: true } } }, }); } async getJoinRequests(tourId, req) { const requests = await this.prisma.joinRequest.findMany({ where: { tourId, status: 'PENDING' }, orderBy: { createdAt: 'desc' }, include: { user: { select: { id: true, name: true, email: true } }, requestedBy: { select: { id: true, name: true, email: true } }, }, }); return requests; } async createJoinRequest(tourId, body, req) { const requestingUserId = body.userId || req.user.id; const existingParticipation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: requestingUserId } }, }); if (existingParticipation) { throw new common_1.BadRequestException('Người dùng này đã là thành viên của tour.'); } const pendingRequest = await this.prisma.joinRequest.findFirst({ where: { tourId, userId: requestingUserId, status: 'PENDING' }, }); if (pendingRequest) { return pendingRequest; } const joinRequest = await this.prisma.joinRequest.create({ data: { tourId, userId: requestingUserId, requestedById: req.user.id, status: 'PENDING', }, include: { user: { select: { id: true, name: true, email: true } }, requestedBy: { select: { id: true, name: true, email: true } }, }, }); return joinRequest; } async acceptJoinRequest(tourId, requestId, req) { let role = req.user.tourParticipation?.role; if (!role) { const participation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: req.user.id } }, }); role = participation?.role; } if (!role || !(role === 'OWNER' || role === 'MANAGER')) { throw new common_1.ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.'); } const joinRequest = await this.prisma.joinRequest.findUnique({ where: { id: requestId }, }); if (!joinRequest || joinRequest.tourId !== tourId) { throw new common_1.NotFoundException('Không tìm thấy yêu cầu tham gia.'); } if (joinRequest.status !== 'PENDING') { throw new common_1.BadRequestException('Yêu cầu này đã được xử lý trước đó.'); } await this.cacheManager.del(`user-role:${joinRequest.userId}:${tourId}`); const existing = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: joinRequest.userId } }, }); if (existing) { await this.prisma.joinRequest.update({ where: { id: requestId }, data: { status: 'REJECTED' }, }); return { success: true, message: 'Người dùng đã là thành viên, yêu cầu đã bị từ chối.' }; } await this.prisma.$transaction([ this.prisma.tourParticipant.create({ data: { tourId, userId: joinRequest.userId, role: 'MEMBER', }, }), this.prisma.joinRequest.update({ where: { id: requestId }, data: { status: 'ACCEPTED' }, }), ]); const tour = await this.prisma.tour.findUnique({ where: { id: tourId }, select: { title: true } }); this.commentGateway.notifyJoinRequestAccepted(joinRequest.userId, { tourId, tourTitle: tour?.title || 'Hành trình', requestId }); return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' }; } async rejectJoinRequest(tourId, requestId, req) { let role = req.user.tourParticipation?.role; if (!role) { const participation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: req.user.id } }, }); role = participation?.role; } if (!role || role !== 'OWNER') { throw new common_1.ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.'); } const joinRequest = await this.prisma.joinRequest.findUnique({ where: { id: requestId }, }); if (!joinRequest || joinRequest.tourId !== tourId) { throw new common_1.NotFoundException('Không tìm thấy yêu cầu tham gia.'); } if (joinRequest.status !== 'PENDING') { throw new common_1.BadRequestException('Yêu cầu này đã được xử lý trước đó.'); } await this.prisma.joinRequest.update({ where: { id: requestId }, data: { status: 'REJECTED' }, }); return { success: true, message: 'Đã từ chối yêu cầu tham gia.' }; } async removeMember(tourId, userId) { const participant = await this.prisma.tourParticipant.findFirst({ where: { OR: [ { id: userId }, { tourId, userId } ] } }); if (!participant) { throw new common_1.NotFoundException('Thành viên này không có trong tour'); } if (participant.userId) { await this.cacheManager.del(`user-role:${participant.userId}:${tourId}`); } await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); await this.prisma.tourParticipant.delete({ where: { id: participant.id }, }); return { message: 'Đã xóa thành viên khỏi tour' }; } async uploadPhotos(tourId, files, req) { if (!files || files.length === 0) { throw new common_1.BadRequestException('Vui lòng chọn ít nhất một ảnh'); } const uploaderId = req.user.id; const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals'); const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours'); if (!fs.existsSync(memberOriginalDir)) fs.mkdirSync(memberOriginalDir, { recursive: true }); if (!fs.existsSync(tourDisplayPath)) fs.mkdirSync(tourDisplayPath, { recursive: true }); const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined; const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined; return Promise.all(files.map(async (file) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg'; const originalFilename = `${uniqueSuffix}${originalExtension}`; const displayFilename = `${uniqueSuffix}.jpg`; const originalFilePath = path.join(memberOriginalDir, originalFilename); const displayFilePath = path.join(tourDisplayPath, displayFilename); await fs.promises.writeFile(originalFilePath, file.buffer); let lat; let lng; try { const gps = await exifr_1.default.gps(file.buffer); if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') { lat = gps.latitude; lng = gps.longitude; } } catch (e) { console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message); } if (lat === undefined || lng === undefined) { if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) { lat = bodyLat; lng = bodyLng; } } if (lat === undefined || lng === undefined) { lat = 10.7769; lng = 106.7009; } let processBuffer = file.buffer; const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif'; if (isHeic) { try { processBuffer = await (0, heic_convert_1.default)({ buffer: file.buffer, format: 'JPEG', quality: 1 }); console.log(`[HEIC] Converted original HEIC image to JPEG for display`); } catch (e) { console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message); } } const uploadsDir = path.dirname(displayFilePath); if (!fs.existsSync(uploadsDir)) { try { fs.mkdirSync(uploadsDir, { recursive: true }); console.log(`[Photo] Created directory: ${uploadsDir}`); } catch (e) { console.error(`[Photo] Failed to create directory ${uploadsDir}:`, e); throw new common_1.BadRequestException(`Lỗi tạo thư mục: ${e.message}`); } } try { await (0, sharp_1.default)(processBuffer) .rotate() .resize(2560, 2560, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 85 }) .toFile(displayFilePath); console.log(`[Photo] Display image saved successfully: ${displayFilePath}`); } catch (e) { console.error(`[Photo] Failed to save display image to ${displayFilePath}:`, e); throw new common_1.BadRequestException(`Lỗi lưu hình ảnh: ${e.message}`); } if (!fs.existsSync(displayFilePath)) { console.error(`[Photo] File verification failed at ${displayFilePath}`); throw new common_1.BadRequestException('Lỗi: Hình ảnh không được lưu thành công. Vui lòng kiểm tra quyền lưu trữ.'); } const photoRecord = await this.prisma.photo.create({ data: { tourId: tourId, uploaderId: uploaderId, imageUrl: `/uploads/tours/${displayFilename}`, originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`, privacy: 'TOUR_ONLY', metadata: { lat: lat, lng: lng } } }); console.log(`[Photo] Database record created: ID=${photoRecord.id}, imageUrl=${photoRecord.imageUrl}`); return photoRecord; })); } async createInvitation(tourId, body, req) { const { email, role = client_1.ParticipantRole.MEMBER } = body; if (!email) { throw new common_1.BadRequestException('Vui lòng cung cấp email mời.'); } const tour = await this.prisma.tour.findUnique({ where: { id: tourId }, select: { title: true } }); if (!tour) { throw new common_1.NotFoundException('Không tìm thấy hành trình.'); } const invitedUser = await this.prisma.user.findUnique({ where: { email } }); if (invitedUser) { const existingParticipant = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: invitedUser.id } } }); if (existingParticipant) { throw new common_1.BadRequestException('Người dùng này đã là thành viên của tour.'); } } const token = crypto.randomBytes(32).toString('hex'); const expiredAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const invitation = await this.prisma.tourInvitation.upsert({ where: { tourId_email: { tourId, email } }, update: { token, expiredAt, role }, create: { tourId, email, token, expiredAt, role } }); const appUrl = process.env.FRONTEND_URL || 'http://localhost:3002'; const inviteLink = `${appUrl}/join-tour?token=${token}`; try { await this.emailService.sendTourInvitation(email, tour.title, inviteLink); } catch (e) { console.error('[Invitation Email Error]:', e); throw new common_1.BadRequestException('Không thể gửi email lời mời. Vui lòng kiểm tra cấu hình SMTP.'); } return { success: true, message: 'Lời mời đã được gửi thành công!' }; } async joinByToken(body, req) { const { token } = body; console.log('[joinByToken] Request received, token length:', token ? token.length : 0); console.log('[joinByToken] Token received (full):', token); if (!token) { console.error('[joinByToken] No token provided'); throw new common_1.BadRequestException('Vui lòng cung cấp token lời mời.'); } console.log('[joinByToken] Searching for invitation with token:', token.substring(0, 20) + '...'); const invitation = await this.prisma.tourInvitation.findUnique({ where: { token }, include: { tour: { select: { id: true, title: true } } } }); if (!invitation) { console.error('[joinByToken] Invitation not found for token:', token.substring(0, 20) + '...'); const totalInvitations = await this.prisma.tourInvitation.count(); console.log('[joinByToken] Total invitations in database:', totalInvitations); const allInvitations = await this.prisma.tourInvitation.findMany({ select: { token: true, email: true, tourId: true } }); console.log('[joinByToken] All invitation tokens:', allInvitations); throw new common_1.NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.'); } const userEmail = req.user.email; console.log('[joinByToken] Checking email - User:', userEmail, 'Invitation:', invitation.email); if (invitation.email && userEmail && invitation.email !== userEmail) { console.error('[joinByToken] Email mismatch - User email:', userEmail, 'Invitation email:', invitation.email); throw new common_1.BadRequestException('Lời mời này được gửi tới một địa chỉ email khác. Vui lòng đăng nhập bằng tài khoản đúng.'); } console.log('[joinByToken] Invitation found, tourId:', invitation.tourId, 'expired:', invitation.expiredAt < new Date()); if (invitation.expiredAt < new Date()) { console.error('[joinByToken] Invitation expired at:', invitation.expiredAt); try { await this.prisma.tourInvitation.delete({ where: { id: invitation.id } }); } catch (e) { } throw new common_1.BadRequestException('Lời mời đã hết hạn.'); } const userId = req.user.id; console.log('[joinByToken] User attempting to join - userId:', userId, 'tourId:', invitation.tourId); const existingParticipation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId: invitation.tourId, userId } } }); if (existingParticipation) { console.log('[joinByToken] User is already a participant'); try { await this.prisma.tourInvitation.delete({ where: { id: invitation.id } }); } catch (e) { } await Promise.all([ this.cacheManager.del(invitation.tourId), this.cacheManager.del(`/api/v1/tours/${invitation.tourId}`), this.cacheManager.del(`/api/v1/tours/${invitation.tourId}/public`), this.cacheManager.del(`/api/v1/tours/explore`), this.cacheManager.del(`user-role:${userId}:${invitation.tourId}`), ]); return { success: true, tourId: invitation.tourId, message: 'Bạn đã là thành viên của tour này.', shouldRefresh: true }; } await Promise.all([ this.cacheManager.del(`user-role:${userId}:${invitation.tourId}`), this.cacheManager.del(invitation.tourId), this.cacheManager.del(`/api/v1/tours/${invitation.tourId}`), this.cacheManager.del(`/api/v1/tours/${invitation.tourId}/public`), this.cacheManager.del(`/api/v1/tours/explore`), ]); await this.prisma.$transaction([ this.prisma.tourParticipant.create({ data: { tourId: invitation.tourId, userId, role: invitation.role } }), this.prisma.tourInvitation.delete({ where: { id: invitation.id } }) ]); console.log('[joinByToken] Successfully joined tour:', invitation.tourId, 'userId:', userId); return { success: true, tourId: invitation.tourId, message: `Bạn đã gia nhập hành trình "${invitation.tour.title}"!`, shouldRefresh: true }; } async mergeMember(tourId, body, req) { const { manualParticipantId, systemUserId } = body; if (!manualParticipantId || !systemUserId) { throw new common_1.BadRequestException('Thiếu thông tin manualParticipantId hoặc systemUserId.'); } const manualParticipant = await this.prisma.tourParticipant.findFirst({ where: { id: manualParticipantId, tourId, userId: null } }); if (!manualParticipant) { throw new common_1.NotFoundException('Không tìm thấy thành viên ngoài hệ thống cần gán.'); } const systemParticipant = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: systemUserId } } }); if (!systemParticipant) { throw new common_1.NotFoundException('Không tìm thấy thành viên hệ thống trong hành trình này.'); } const nextAdult = Math.max(systemParticipant.adultCount, manualParticipant.adultCount); const nextChild = Math.max(systemParticipant.childCount, manualParticipant.childCount); await this.prisma.$transaction([ this.prisma.tourParticipant.update({ where: { id: systemParticipant.id }, data: { adultCount: nextAdult, childCount: nextChild, role: manualParticipant.role !== client_1.ParticipantRole.MEMBER ? manualParticipant.role : systemParticipant.role } }), this.prisma.tourParticipant.delete({ where: { id: manualParticipant.id } }) ]); await this.cacheManager.del(`user-role:${systemUserId}:${tourId}`); await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), this.cacheManager.del(`/api/v1/tours/explore`) ]); return { success: true, message: 'Đã gán và hợp nhất thông tin thành viên thành công.' }; } }; __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Post)(), __param(0, (0, common_1.Body)()), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "createTour", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/locations'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "addLocation", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/start-point'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "updateTourStartPoint", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/end-point'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "updateTourEndPoint", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/legs/batch'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "initializeLegs", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/legs'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "addLeg", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Patch)(':id'), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "updateTour", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Delete)(':id'), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], TourController.prototype, "deleteTour", null); __decorate([ (0, common_1.UseInterceptors)(compress_cache_interceptor_1.CompressCacheInterceptor), (0, common_1.UseGuards)(jwt_auth_guard_2.JwtAuthGuardNoAnonymous), (0, common_1.Get)('explore'), __param(0, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "getPublicTours", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Get)(':id'), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], TourController.prototype, "getTourDetails", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/members'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "addMember", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Patch)(':tourId/members/:userId'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)), __param(2, (0, common_1.Body)()), __param(3, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, String, Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "updateMemberCounts", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Get)(':tourId/join-requests'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "getJoinRequests", null); __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Post)(':tourId/join-requests'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "createJoinRequest", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/join-requests/:requestId/accept'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Param)('requestId')), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, String, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "acceptJoinRequest", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/join-requests/:requestId/reject'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Param)('requestId')), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, String, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "rejectJoinRequest", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Delete)(':tourId/members/:userId'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String, String]), __metadata("design:returntype", Promise) ], TourController.prototype, "removeMember", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/photos'), (0, common_1.UseInterceptors)((0, platform_express_1.FilesInterceptor)('images', 10)), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.UploadedFiles)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Array, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "uploadPhotos", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/invitations'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "createInvitation", null); __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Post)('join-by-token'), __param(0, (0, common_1.Body)()), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "joinByToken", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':tourId/members/merge'), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], TourController.prototype, "mergeMember", null); TourController = __decorate([ (0, common_1.Controller)('tours'), __param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)), __metadata("design:paramtypes", [prisma_service_1.PrismaService, EmailService, Object, CommentGateway]) ], TourController); let LocationController = class LocationController { constructor(prisma, cacheManager) { this.prisma = prisma; this.cacheManager = cacheManager; } async updateLocation(id, body, req) { const { expenseAmount, expenseCategory, ...data } = body; const location = await this.prisma.location.update({ where: { id }, data: { name: data.name, address: data.address, latitude: data.latitude, longitude: data.longitude, type: data.type, plannedStart: data.plannedStart ? new Date(data.plannedStart) : undefined, plannedEnd: data.plannedEnd ? new Date(data.plannedEnd) : undefined, status: data.status, } }); if (expenseAmount !== undefined) { const amount = Number(expenseAmount); const existingExpense = await this.prisma.expense.findFirst({ where: { locationId: id } }); if (existingExpense) { await this.prisma.expense.update({ where: { id: existingExpense.id }, data: { amount, category: expenseCategory || 'OTHER' } }); } else if (amount > 0) { await this.prisma.expense.create({ data: { amount, category: expenseCategory || 'OTHER', locationId: id, legId: location.legId, description: `Chi phí tại ${location.name}` } }); } } if (req.tourId) { await Promise.all([ this.cacheManager.del(req.tourId), this.cacheManager.del(`/api/v1/tours/${req.tourId}`), this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`), ]); } return location; } async deleteLocation(id, req) { try { await this.prisma.location.delete({ where: { id } }); } catch (e) { } await this.cacheManager.del(`res-to-tour:${id}`); if (req.tourId) { await Promise.all([ this.cacheManager.del(req.tourId), this.cacheManager.del(`/api/v1/tours/${req.tourId}`), this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`), ]); } return { success: true }; } }; __decorate([ (0, common_1.Patch)(':id'), (0, common_1.UseGuards)(TourRoleGuard), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], LocationController.prototype, "updateLocation", null); __decorate([ (0, common_1.Delete)(':id'), (0, common_1.UseGuards)(TourRoleGuard), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], LocationController.prototype, "deleteLocation", null); LocationController = __decorate([ (0, common_1.Controller)('locations'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), __param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)), __metadata("design:paramtypes", [prisma_service_1.PrismaService, Object]) ], LocationController); let LegController = class LegController { constructor(prisma, cacheManager) { this.prisma = prisma; this.cacheManager = cacheManager; } async updateLeg(id, body, req) { return this.prisma.leg.update({ where: { id }, data: { note: body.note, sequence: body.sequence, startDate: body.startDate ? new Date(body.startDate) : undefined, endDate: body.endDate ? new Date(body.endDate) : undefined, description: body.description, } }).then(async (leg) => { await Promise.all([ this.cacheManager.del(req.tourId), this.cacheManager.del(`/api/v1/tours/${req.tourId}`), this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`), ]); return leg; }); } async deleteLeg(id) { const leg = await this.prisma.leg.findUnique({ where: { id }, include: { _count: { select: { locations: true } } } }); if (leg?._count.locations && leg._count.locations > 0) { throw new common_1.BadRequestException('Không thể xóa chặng đang có địa điểm. Hãy xóa địa điểm trước.'); } try { const deletedLeg = await this.prisma.leg.delete({ where: { id } }); await this.cacheManager.del(`res-to-tour:${id}`); if (deletedLeg.tourId) { await Promise.all([ this.cacheManager.del(deletedLeg.tourId), this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}`), this.cacheManager.del(`/api/v1/tours/${deletedLeg.tourId}/public`), ]); } } catch (e) { } return { success: true }; } }; __decorate([ (0, common_1.Patch)(':id'), (0, common_1.UseGuards)(TourRoleGuard), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], LegController.prototype, "updateLeg", null); __decorate([ (0, common_1.Delete)(':id'), (0, common_1.UseGuards)(TourRoleGuard), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], LegController.prototype, "deleteLeg", null); LegController = __decorate([ (0, common_1.Controller)('legs'), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), __param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)), __metadata("design:paramtypes", [prisma_service_1.PrismaService, Object]) ], LegController); function calculateDistance(lat1, lon1, lat2, lon2) { const p = 0.017453292519943295; const c = Math.cos; const a = 0.5 - c((lat2 - lat1) * p) / 2 + c(lat1 * p) * c(lat2 * p) * (1 - c((lon2 - lon1) * p)) / 2; return 12742 * Math.asin(Math.sqrt(a)); } let RoutingController = class RoutingController { constructor(prisma, cacheManager) { this.prisma = prisma; this.cacheManager = cacheManager; } async optimize(legId, req) { const currentLeg = await this.prisma.leg.findUnique({ where: { id: legId }, }); if (!currentLeg) throw new common_1.NotFoundException('Không tìm thấy chặng'); const locations = await this.prisma.location.findMany({ where: { legId }, }); if (locations.length === 0) return { locations: [], totalDistance: 0 }; let startAnchor = null; const prevLeg = await this.prisma.leg.findFirst({ where: { tourId: currentLeg.tourId, sequence: currentLeg.sequence - 1 }, include: { locations: { orderBy: [ { plannedStart: { sort: 'asc', nulls: 'last' } }, { createdAt: 'asc' } ] } } }); if (prevLeg?.locations?.length) { startAnchor = prevLeg.locations[prevLeg.locations.length - 1]; } if (locations.length <= 2 && !startAnchor) return { locations, totalDistance: 0 }; const optimized = []; const unvisited = [...locations]; let current; if (startAnchor) { let nearestIdx = 0; let minDist = Infinity; for (let i = 0; i < unvisited.length; i++) { const d = calculateDistance(startAnchor.latitude, startAnchor.longitude, unvisited[i].latitude, unvisited[i].longitude); if (d < minDist) { minDist = d; nearestIdx = i; } } current = unvisited.splice(nearestIdx, 1)[0]; } else { current = unvisited.sort((a, b) => (a.plannedStart?.getTime() || 0) - (b.plannedStart?.getTime() || 0)).shift(); } optimized.push(current); while (unvisited.length > 0) { let nearestIdx = 0; let minDist = Infinity; for (let i = 0; i < unvisited.length; i++) { const d = calculateDistance(current.latitude, current.longitude, unvisited[i].latitude, unvisited[i].longitude); if (d < minDist) { minDist = d; nearestIdx = i; } } current = unvisited.splice(nearestIdx, 1)[0]; optimized.push(current); } let totalDistance = 0; if (startAnchor) { totalDistance += calculateDistance(startAnchor.latitude, startAnchor.longitude, optimized[0].latitude, optimized[0].longitude); } for (let i = 0; i < optimized.length - 1; i++) { totalDistance += calculateDistance(optimized[i].latitude, optimized[i].longitude, optimized[i + 1].latitude, optimized[i + 1].longitude); } const baseTime = optimized[0].plannedStart || new Date(); await Promise.all(optimized.map((loc, index) => this.prisma.location.update({ where: { id: loc.id }, data: { plannedStart: new Date(baseTime.getTime() + index * 3600000) }, }))); const updatedLocations = await this.prisma.location.findMany({ where: { legId }, orderBy: [ { plannedStart: { sort: 'asc', nulls: 'last' } }, { createdAt: 'asc' } ] }); if (req.tourId) { await Promise.all([ this.cacheManager.del(req.tourId), this.cacheManager.del(`/api/v1/tours/${req.tourId}`), this.cacheManager.del(`/api/v1/tours/${req.tourId}/public`), ]); } return { locations: updatedLocations, totalDistance: parseFloat(totalDistance.toFixed(2)) }; } }; __decorate([ (0, common_1.Post)('optimize/:legId'), (0, common_1.UseGuards)(TourRoleGuard), __param(0, (0, common_1.Param)('legId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], RoutingController.prototype, "optimize", null); RoutingController = __decorate([ (0, common_1.Controller)('routing'), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), __param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)), __metadata("design:paramtypes", [prisma_service_1.PrismaService, Object]) ], RoutingController); let PhotoController = class PhotoController { constructor(prisma) { this.prisma = prisma; } async uploadAnonymousPhoto(files, req) { if (!files || files.length === 0) { throw new common_1.BadRequestException('Vui lòng chọn một ảnh.'); } const uploaderId = req.user.id; const isAnonymous = req.user.isAnonymous; if (!isAnonymous) { throw new common_1.ForbiddenException('Chỉ người dùng khách mới có thể sử dụng tính năng này.'); } const file = files[0]; const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg'; const originalFilename = `${uniqueSuffix}${originalExtension}`; const displayFilename = `${uniqueSuffix}.jpg`; const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals'); const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours'); const displayFilePath = path.join(tourDisplayPath, displayFilename); const originalFilePath = path.join(memberOriginalDir, originalFilename); if (!fs.existsSync(memberOriginalDir)) { fs.mkdirSync(memberOriginalDir, { recursive: true }); } if (!fs.existsSync(tourDisplayPath)) { fs.mkdirSync(tourDisplayPath, { recursive: true }); } await fs.promises.writeFile(originalFilePath, file.buffer); let lat; let lng; try { const gps = await exifr_1.default.gps(file.buffer); if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') { lat = gps.latitude; lng = gps.longitude; console.log(`[EXIF GPS] Đã tìm thấy tọa độ từ EXIF: lat=${lat}, lng=${lng}`); } } catch (e) { console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message); } if (lat === undefined || lng === undefined) { const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined; const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined; if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) { lat = bodyLat; lng = bodyLng; console.log(`[EXIF GPS] Sử dụng tọa độ dự phòng từ Frontend: lat=${lat}, lng=${lng}`); } } let tags = []; if (req.body.tags) { try { tags = JSON.parse(req.body.tags); if (!Array.isArray(tags)) { tags = []; } } catch (e) { const errorMsg = e instanceof Error ? e.message : 'Unknown error'; console.warn('[TAGS] Failed to parse tags from request:', errorMsg); } } if (lat === undefined || lng === undefined) { lat = 10.7769; lng = 106.7009; console.log(`[EXIF GPS] Không tìm thấy tọa độ nào, ghim tại TP.HCM mặc định: lat=${lat}, lng=${lng}`); } let processBuffer = file.buffer; const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif'; if (isHeic) { try { processBuffer = await (0, heic_convert_1.default)({ buffer: file.buffer, format: 'JPEG', quality: 1 }); console.log(`[HEIC] Converted anonymous HEIC image to JPEG for display`); } catch (e) { console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message); } } await (0, sharp_1.default)(processBuffer) .rotate() .resize(2560, 2560, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 85 }) .toFile(displayFilePath); return this.prisma.photo.create({ data: { uploaderId: uploaderId, imageUrl: `/uploads/tours/${displayFilename}`, originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`, privacy: 'PUBLIC', metadata: { lat: lat, lng: lng, tags: tags } }, }); } async deletePhoto(id, req) { const photo = await this.prisma.photo.findUnique({ where: { id }, }); if (!photo) { throw new common_1.NotFoundException('Không tìm thấy ảnh.'); } if (photo.uploaderId !== req.user.id && !req.user.isAdmin) { throw new common_1.ForbiddenException('Bạn không có quyền xóa ảnh này.'); } await this.prisma.photo.update({ where: { id }, data: { isDeleted: true, deletedAt: new Date() } }); return { message: 'Ảnh đã được chuyển vào thùng rác.' }; } async updatePhoto(id, body, req) { const photo = await this.prisma.photo.findUnique({ where: { id } }); if (!photo) { throw new common_1.NotFoundException('Không tìm thấy ảnh.'); } if (photo.uploaderId !== req.user.id && !req.user.isAdmin) { throw new common_1.ForbiddenException('Bạn không có quyền chỉnh sửa thông tin của bức ảnh này.'); } const currentMetadata = photo.metadata || {}; const updatedMetadata = { ...currentMetadata, lat: body.latitude !== undefined ? body.latitude : currentMetadata.lat, lng: body.longitude !== undefined ? body.longitude : currentMetadata.lng, title: body.title !== undefined ? body.title : currentMetadata.title, description: body.description !== undefined ? body.description : currentMetadata.description, }; return this.prisma.photo.update({ where: { id }, data: { metadata: updatedMetadata } }); } async toggleLikePhoto(id, req) { const userId = req.user.id; const photo = await this.prisma.photo.findUnique({ where: { id } }); if (!photo) { throw new common_1.NotFoundException('Không tìm thấy ảnh.'); } const currentMetadata = photo.metadata || {}; const likedUserIds = Array.isArray(currentMetadata.likedUserIds) ? currentMetadata.likedUserIds : []; const index = likedUserIds.indexOf(userId); let updatedLikedUserIds = [...likedUserIds]; if (index > -1) { updatedLikedUserIds.splice(index, 1); } else { updatedLikedUserIds.push(userId); } const updatedMetadata = { ...currentMetadata, likedUserIds: updatedLikedUserIds }; return this.prisma.photo.update({ where: { id }, data: { metadata: updatedMetadata }, include: { uploader: { select: { id: true, name: true } } } }); } }; __decorate([ (0, common_1.Post)('upload-anonymous'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.UseInterceptors)((0, platform_express_1.FilesInterceptor)('images', 1)), __param(0, (0, common_1.UploadedFiles)()), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Array, Object]), __metadata("design:returntype", Promise) ], PhotoController.prototype, "uploadAnonymousPhoto", null); __decorate([ (0, common_1.Delete)(':id'), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], PhotoController.prototype, "deletePhoto", null); __decorate([ (0, common_1.Patch)(':id'), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], PhotoController.prototype, "updatePhoto", null); __decorate([ (0, common_1.Post)(':id/toggle-like'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], PhotoController.prototype, "toggleLikePhoto", null); PhotoController = __decorate([ (0, common_1.Controller)('photos'), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], PhotoController); let UserController = class UserController { constructor(prisma) { this.prisma = prisma; } async getAllUsers(req, q) { const currentUserId = req.user?.id; const users = await this.prisma.user.findMany({ where: { isAnonymous: false, ...(q ? { OR: [ { name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }, ], } : {}), }, select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true } }); return users.filter((u) => u.id !== currentUserId); } async getMyPhotos(req) { return this.prisma.photo.findMany({ where: { uploaderId: req.user.id, isDeleted: false }, include: { tour: { select: { title: true } } }, orderBy: { capturedAt: 'desc' } }); } async updateUser(id, data, req) { const requestingUser = req.user; const targetUser = await this.prisma.user.findUnique({ where: { id } }); if (!targetUser) { throw new common_1.NotFoundException('Không tìm thấy người dùng'); } if (targetUser.isAdmin && !requestingUser.isAdmin) { throw new common_1.ForbiddenException('Không có quyền thay đổi thông tin hoặc reset password của Quản trị viên'); } if (!requestingUser.isAdmin && requestingUser.id !== id) { throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này'); } if (data.password) { data.passwordHash = await bcrypt.hash(data.password, 10); delete data.password; } if (!requestingUser.isAdmin && data.isAdmin !== undefined) { delete data.isAdmin; } return this.prisma.user.update({ where: { id }, data, select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true } }); } async deleteUser(id, req) { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new common_1.NotFoundException('Không tìm thấy người dùng'); if (user.isAdmin) { const adminCount = await this.prisma.user.count({ where: { isAdmin: true } }); if (adminCount <= 1) throw new common_1.BadRequestException('Không thể xóa Quản trị viên cuối cùng'); } const memberDir = path.join(UPLOAD_ROOT, 'members', id); const photos = await this.prisma.photo.findMany({ where: { uploaderId: id } }); for (const photo of photos) { if (photo.imageUrl) { const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); if (fs.existsSync(displayFilePath)) fs.unlinkSync(displayFilePath); } } await this.prisma.photo.deleteMany({ where: { uploaderId: id } }); await this.prisma.tourParticipant.deleteMany({ where: { userId: id } }); await this.prisma.user.delete({ where: { id } }); if (fs.existsSync(memberDir)) { fs.rmSync(memberDir, { recursive: true, force: true }); } return { message: 'Đã xóa người dùng' }; } async toggleBlock(id, req) { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new common_1.NotFoundException('Người dùng không tồn tại'); if (user.isAdmin) { throw new common_1.BadRequestException('Không thể khóa tài khoản Quản trị viên'); } const updated = await this.prisma.user.update({ where: { id }, data: { isBlocked: !user.isBlocked }, select: { id: true, email: true, name: true, isBlocked: true } }); return updated; } }; __decorate([ (0, common_1.Get)(), __param(0, (0, common_1.Req)()), __param(1, (0, common_1.Query)('q')), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, String]), __metadata("design:returntype", Promise) ], UserController.prototype, "getAllUsers", null); __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Get)('me/photos'), __param(0, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], UserController.prototype, "getMyPhotos", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.Patch)(':id'), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], UserController.prototype, "updateUser", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER), (0, common_1.Delete)(':id'), (0, common_1.UseGuards)(admin_guard_1.AdminGuard), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], UserController.prototype, "deleteUser", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, common_1.Post)('block/:id'), (0, common_1.UseGuards)(admin_guard_1.AdminGuard), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], UserController.prototype, "toggleBlock", null); UserController = __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Controller)('users'), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], UserController); let CommentController = class CommentController { constructor(prisma, commentGateway) { this.prisma = prisma; this.commentGateway = commentGateway; } async getComments(locationId) { return this.prisma.comment.findMany({ where: { locationId }, include: { user: { select: { name: true } } }, orderBy: { createdAt: 'asc' } }); } async addComment(locationId, body, req) { const filteredContent = await filterText(this.prisma, body.content); const comment = await this.prisma.comment.create({ data: { content: filteredContent, locationId, userId: req.user.id }, include: { user: { select: { name: true } } } }); const location = await this.prisma.location.findUnique({ where: { id: locationId }, include: { leg: { select: { tourId: true } } } }); if (location?.leg?.tourId) { this.commentGateway.notifyNewComment(location.leg.tourId, { ...comment, locationId }); } return comment; } }; __decorate([ (0, common_1.Get)(':locationId/comments'), __param(0, (0, common_1.Param)('locationId', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], CommentController.prototype, "getComments", null); __decorate([ (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.Post)(':locationId/comments'), __param(0, (0, common_1.Param)('locationId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Body)()), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], CommentController.prototype, "addComment", null); CommentController = __decorate([ (0, common_1.Controller)('locations'), __metadata("design:paramtypes", [prisma_service_1.PrismaService, CommentGateway]) ], CommentController); let AdminOtpController = class AdminOtpController { constructor(prisma, emailService, cacheManager) { this.prisma = prisma; this.emailService = emailService; this.cacheManager = cacheManager; } async sendOtpToUser(body, req) { const { email } = body; const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress; const emailLimitKey = `rl:otp:email:${email}`; const ipLimitKey = `rl:otp:ip:${clientIp}`; const [isEmailLimited, isIpLimited] = await Promise.all([ this.cacheManager.get(emailLimitKey), this.cacheManager.get(ipLimitKey) ]); if (isEmailLimited || isIpLimited) { throw new common_1.HttpException('Thao tác quá nhanh. Vui lòng đợi 60 giây giữa mỗi lần yêu cầu gửi mã.', common_1.HttpStatus.TOO_MANY_REQUESTS); } const user = await this.prisma.user.findUnique({ where: { email } }); if (!user) { throw new common_1.NotFoundException('Không tìm thấy người dùng với email này'); } const otp = Math.floor(100000 + Math.random() * 900000).toString(); const otpCacheKey = `otp:${email}`; await this.cacheManager.set(otpCacheKey, otp, 300000); try { await this.emailService.sendOTP(email, otp); await Promise.all([ this.cacheManager.set(emailLimitKey, true, 60000), this.cacheManager.set(ipLimitKey, true, 60000) ]); return { success: true, message: `Đã gửi mã OTP tới email ${email} thành công.` }; } catch (error) { console.error('[Admin OTP] SMTP Error:', error); throw new common_1.BadRequestException('Lỗi cấu hình SMTP hoặc không thể kết nối tới máy chủ gửi mail'); } } async verifyOtp(body) { const { email, otp } = body; const otpCacheKey = `otp:${email}`; const failCountKey = `otp_fails:${email}`; const MAX_FAILED_ATTEMPTS = 5; const user = await this.prisma.user.findUnique({ where: { email } }); if (!user) throw new common_1.NotFoundException('Người dùng không tồn tại'); if (user.isBlocked) { throw new common_1.ForbiddenException('Tài khoản này hiện đang bị khóa. Vui lòng liên hệ quản trị viên.'); } const storedOtp = await this.cacheManager.get(otpCacheKey); if (!storedOtp) { throw new common_1.BadRequestException('Mã OTP đã hết hạn hoặc không tồn tại. Vui lòng yêu cầu mã mới.'); } if (storedOtp === otp) { await Promise.all([ this.cacheManager.del(otpCacheKey), this.cacheManager.del(failCountKey) ]); return { success: true, message: 'Xác thực mã OTP thành công.' }; } else { let fails = (await this.cacheManager.get(failCountKey)) || 0; fails++; if (fails >= MAX_FAILED_ATTEMPTS) { await this.prisma.user.update({ where: { email }, data: { isBlocked: true } }); await this.cacheManager.del(failCountKey); await this.cacheManager.del(otpCacheKey); throw new common_1.ForbiddenException(`Bạn đã nhập sai quá ${MAX_FAILED_ATTEMPTS} lần. Tài khoản đã bị khóa để bảo mật.`); } else { await this.cacheManager.set(failCountKey, fails, 300000); throw new common_1.BadRequestException({ message: `Mã OTP không chính xác. Bạn còn ${MAX_FAILED_ATTEMPTS - fails} lần thử.`, remainingAttempts: MAX_FAILED_ATTEMPTS - fails }); } } } }; __decorate([ (0, common_1.Post)('send'), __param(0, (0, common_1.Body)()), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Object]), __metadata("design:returntype", Promise) ], AdminOtpController.prototype, "sendOtpToUser", null); __decorate([ (0, common_1.Post)('verify'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AdminOtpController.prototype, "verifyOtp", null); AdminOtpController = __decorate([ (0, common_1.Controller)('admin/otp'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard), __param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)), __metadata("design:paramtypes", [prisma_service_1.PrismaService, EmailService, Object]) ], AdminOtpController); let AdminController = class AdminController { constructor(prisma) { this.prisma = prisma; } async getTrashPhotos() { const dbPhotos = await this.prisma.photo.findMany({ select: { id: true, imageUrl: true, originalUrl: true, privacy: true, tourId: true, } }); const dbImageUrls = new Set(dbPhotos.map(p => p.imageUrl).filter(Boolean)); const dbOriginalUrls = new Set(dbPhotos.map(p => p.originalUrl).filter(Boolean)); const allDiskFiles = []; const getFilesRecursively = (dir) => { if (!fs.existsSync(dir)) return; const list = fs.readdirSync(dir); for (const file of list) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { getFilesRecursively(fullPath); } else { allDiskFiles.push(fullPath); } } }; getFilesRecursively(UPLOAD_ROOT); const trashPhotos = []; for (const filePath of allDiskFiles) { const relativePath = '/uploads' + filePath.substring(UPLOAD_ROOT.length).replace(/\\/g, '/'); if (path.basename(filePath).startsWith('.')) continue; const isUsed = dbImageUrls.has(relativePath) || dbOriginalUrls.has(relativePath); if (!isUsed) { let size = 0; try { size = fs.statSync(filePath).size; } catch (e) { } trashPhotos.push({ filePath: filePath, url: relativePath, size: size, reason: 'Tệp tin không tồn tại trong cơ sở dữ liệu (ảnh mồ côi)', type: 'file_only' }); } } const orphanedDbPhotos = dbPhotos.filter(p => !p.tourId && p.privacy !== 'PUBLIC'); for (const dbPhoto of orphanedDbPhotos) { let size = 0; if (dbPhoto.imageUrl) { const fullPath = path.join(process.cwd(), dbPhoto.imageUrl.replace(/^\//, '')); try { if (fs.existsSync(fullPath)) { size += fs.statSync(fullPath).size; } } catch (e) { } } if (dbPhoto.originalUrl) { const fullPath = path.join(process.cwd(), dbPhoto.originalUrl.replace(/^\//, '')); try { if (fs.existsSync(fullPath)) { size += fs.statSync(fullPath).size; } } catch (e) { } } trashPhotos.push({ id: dbPhoto.id, url: dbPhoto.imageUrl, size: size, reason: 'Bản ghi ảnh riêng tư không gắn với chuyến đi nào', type: 'db_orphaned' }); } return trashPhotos; } async cleanTrashPhotos() { const trashList = await this.getTrashPhotos(); let deletedCount = 0; let freedSpace = 0; for (const item of trashList) { if (item.type === 'file_only') { if (item.filePath && fs.existsSync(item.filePath)) { try { fs.unlinkSync(item.filePath); deletedCount++; freedSpace += item.size; } catch (e) { } } } else if (item.type === 'db_orphaned') { const photo = await this.prisma.photo.findUnique({ where: { id: item.id } }); if (photo) { if (photo.imageUrl) { const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); if (fs.existsSync(displayFilePath)) { try { fs.unlinkSync(displayFilePath); } catch (e) { } } } if (photo.originalUrl) { const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, '')); if (fs.existsSync(originalFilePath)) { try { fs.unlinkSync(originalFilePath); } catch (e) { } } } try { await this.prisma.photo.delete({ where: { id: item.id } }); deletedCount++; freedSpace += item.size; } catch (e) { } } } } return { success: true, message: `Đã dọn dẹp thành công. Đã xóa ${deletedCount} mục, giải phóng ${(freedSpace / (1024 * 1024)).toFixed(2)} MB.`, deletedCount, freedSpace }; } async deleteSingleTrash(body) { const { type, filePath, id } = body; if (type === 'file_only') { if (!filePath) throw new common_1.BadRequestException('Đường dẫn tệp tin không hợp lệ'); const resolvedPath = path.resolve(filePath); if (!resolvedPath.startsWith(UPLOAD_ROOT)) { throw new common_1.ForbiddenException('Không được phép xóa tệp tin ngoài thư mục uploads'); } if (fs.existsSync(resolvedPath)) { fs.unlinkSync(resolvedPath); return { success: true, message: 'Đã xóa tệp tin thành công' }; } else { throw new common_1.NotFoundException('Tệp tin không tồn tại'); } } else if (type === 'db_orphaned') { if (!id) throw new common_1.BadRequestException('ID bản ghi không hợp lệ'); const photo = await this.prisma.photo.findUnique({ where: { id } }); if (!photo) throw new common_1.NotFoundException('Bản ghi không tồn tại trong cơ sở dữ liệu'); if (photo.imageUrl) { const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); if (fs.existsSync(displayFilePath)) { try { fs.unlinkSync(displayFilePath); } catch (e) { } } } if (photo.originalUrl) { const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, '')); if (fs.existsSync(originalFilePath)) { try { fs.unlinkSync(originalFilePath); } catch (e) { } } } await this.prisma.photo.delete({ where: { id } }); return { success: true, message: 'Đã xóa bản ghi và các tệp liên quan thành công' }; } throw new common_1.BadRequestException('Yêu cầu không hợp lệ'); } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminController.prototype, "getTrashPhotos", null); __decorate([ (0, common_1.Delete)('clean'), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminController.prototype, "cleanTrashPhotos", null); __decorate([ (0, common_1.Delete)('delete-single'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AdminController.prototype, "deleteSingleTrash", null); AdminController = __decorate([ (0, common_1.Controller)('admin/trash-photos'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], AdminController); let PublicPhotoController = class PublicPhotoController { constructor(prisma, commentGateway) { this.prisma = prisma; this.commentGateway = commentGateway; } async getPublicPhotos() { return this.prisma.photo.findMany({ where: { privacy: 'PUBLIC', isDeleted: false }, select: { id: true, imageUrl: true, originalUrl: true, capturedAt: true, metadata: true, uploaderId: true, uploader: { select: { id: true, name: true, } } }, orderBy: { capturedAt: 'desc' } }); } async getPhotoComments(photoId) { return this.prisma.comment.findMany({ where: { photoId }, include: { user: { select: { id: true, name: true } } }, orderBy: { createdAt: 'asc' } }); } async addPhotoComment(photoId, body, req) { const { content } = body; if (!content || content.trim() === '') { throw new common_1.BadRequestException('Nội dung bình luận không được để trống.'); } const photo = await this.prisma.photo.findUnique({ where: { id: photoId } }); if (!photo || photo.isDeleted) { throw new common_1.NotFoundException('Không tìm thấy ảnh.'); } const filteredContent = await filterText(this.prisma, content.trim()); const comment = await this.prisma.comment.create({ data: { content: filteredContent, photoId, userId: req.user.id }, include: { user: { select: { id: true, name: true } } } }); this.commentGateway.notifyNewPhotoComment(photoId, comment); return comment; } async deletePhotoComment(id, req) { const comment = await this.prisma.comment.findUnique({ where: { id }, include: { photo: true } }); if (!comment) { throw new common_1.NotFoundException('Không tìm thấy bình luận.'); } const isAuthorized = req.user.isAdmin || comment.userId === req.user.id || (comment.photo && comment.photo.uploaderId === req.user.id); if (!isAuthorized) { throw new common_1.ForbiddenException('Bạn không có quyền xóa bình luận này.'); } await this.prisma.comment.delete({ where: { id } }); if (comment.photoId) { this.commentGateway.server.to(`photo_${comment.photoId}`).emit('photoCommentDeleted', { id, photoId: comment.photoId }); } return { success: true, message: 'Đã xóa bình luận thành công.' }; } async sharePhoto(photoId, req, res) { const photo = await this.prisma.photo.findUnique({ where: { id: photoId } }); if (!photo || photo.isDeleted) { throw new common_1.NotFoundException('Không tìm thấy ảnh.'); } let host = req.headers['x-forwarded-host'] || req.headers.host || ''; const isProxied = !!(req.headers['x-forwarded-proto'] || req.headers['x-forwarded-for']); const isLocalhost = host.includes('localhost') || host.includes('127.0.0.1'); if (isLocalhost && isProxied) { host = 'yotrip.labz.io.vn'; } const isLocal = host.includes('localhost') || host.includes('127.0.0.1') || host.startsWith('192.168.') || host.startsWith('10.'); const protocol = isLocal ? (req.headers['x-forwarded-proto'] || 'http') : 'https'; const baseUrl = `${protocol}://${host}`; let title = 'YoTrip - Xem ảnh công khai'; let description = 'Xem hình ảnh chia sẻ công khai trên bản đồ hành trình YoTrip.'; if (photo.metadata && typeof photo.metadata === 'object') { const meta = photo.metadata; if (meta.title && meta.title.trim()) { title = meta.title; } if (meta.description && meta.description.trim()) { description = meta.description; } } const fullImageUrl = photo.imageUrl ? (photo.imageUrl.startsWith('http') ? photo.imageUrl : `${baseUrl}${photo.imageUrl}`) : ''; const shareUrl = `${baseUrl}/api/v1/public-photos/${photoId}/share`; const redirectUrl = `${baseUrl}/?photoId=${photoId}`; let imageWidth = 1200; let imageHeight = 630; try { if (photo.imageUrl) { const localImagePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); if (fs.existsSync(localImagePath)) { const imageMeta = await (0, sharp_1.default)(localImagePath).metadata(); if (imageMeta.width && imageMeta.height) { imageWidth = imageMeta.width; imageHeight = imageMeta.height; } } } } catch (err) { console.warn('[Share] Không thể lấy kích thước ảnh:', err.message); } const htmlContent = `Nếu trang không tự động tải, nhấn vào đây.