"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: `

Mã Xác Thực OTP

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:

${otp}

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.

`, }; try { return await this.transporter.sendMail(mailOptions); } catch (error) { throw error; } } async sendTourInvitation(email, tourTitle, inviteLink) { 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: `Lời mời tham gia hành trình: ${tourTitle}`, html: `

Lời Mời Tham Gia Hành Trình

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:

Tham Gia Hành Trình Ngay

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.

`, }; try { return await this.transporter.sendMail(mailOptions); } catch (error) { throw error; } } }; exports.EmailService = EmailService; exports.EmailService = EmailService = __decorate([ (0, common_1.Injectable)(), __metadata("design:paramtypes", []) ], EmailService); let AppController = class AppController { getHello() { return 'Travel Planning API is running!'; } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", String) ], AppController.prototype, "getHello", null); AppController = __decorate([ (0, common_1.Controller)() ], AppController); let AuthController = class AuthController { constructor(prisma, jwtService, configService, emailService, cacheManager) { this.prisma = prisma; this.jwtService = jwtService; this.configService = configService; this.emailService = emailService; this.cacheManager = cacheManager; } async convertGuestToOfficial(body) { const { guestId, email, password, name } = body; if (!guestId || !email || !password) { throw new common_1.BadRequestException('Vui lòng cung cấp đủ guestId, email và mật khẩu.'); } const existingOfficialUser = await this.prisma.user.findFirst({ where: { email: email, isAnonymous: false, }, }); if (existingOfficialUser) { throw new common_1.BadRequestException('Email này đã được một tài khoản khác sử dụng.'); } const guestUser = await this.prisma.user.findUnique({ where: { id: guestId }, }); if (!guestUser || !guestUser.isAnonymous) { throw new common_1.NotFoundException('Không tìm thấy tài khoản khách hoặc tài khoản này đã được chuyển đổi.'); } const passwordHash = await bcrypt.hash(password, 10); const updatedUser = await this.prisma.user.update({ where: { id: guestId }, data: { email: email, passwordHash: passwordHash, name: name || guestUser.name, isAnonymous: false, }, }); const payload = { email: updatedUser.email, sub: updatedUser.id }; return { access_token: this.jwtService.sign(payload), user: { id: updatedUser.id, email: updatedUser.email, name: updatedUser.name, isAdmin: updatedUser.isAdmin, }, }; } async promoteAdmin(body, req) { const { secretKey } = body; const adminSecret = this.configService.get('ADMIN_SECRET_KEY') || 'yotrip_secret_admin_key'; if (secretKey !== adminSecret) { throw new common_1.BadRequestException('Mã Secret Key không hợp lệ.'); } const updated = await this.prisma.user.update({ where: { id: req.user.id }, data: { isAdmin: true }, select: { id: true, email: true, name: true, isAdmin: true } }); return { success: true, user: updated }; } async createGuestUser() { const user = await this.prisma.user.create({ data: { isAnonymous: true, name: `Lữ khách #${Math.floor(1000 + Math.random() * 9000)}`, }, }); const payload = { sub: user.id, isAnonymous: true }; return { access_token: this.jwtService.sign(payload), user: { id: user.id, name: user.name, isAnonymous: user.isAnonymous, }, }; } async getStatus() { const userCount = await this.prisma.user.count(); console.log(`[Status Check] Users found: ${userCount}`); return { isInitialSetup: userCount === 0 }; } async login(body) { const { email, password } = body; const adminSecret = this.configService.get('ADMIN_SECRET_KEY') || 'yotrip_secret_admin_key'; if ((email === 'admin' || email === 'admin@yotrip.com') && password === adminSecret) { let adminUser = await this.prisma.user.findFirst({ where: { OR: [ { email: 'admin' }, { email: 'admin@yotrip.com' } ] } }); if (!adminUser) { adminUser = await this.prisma.user.create({ data: { email: 'admin@yotrip.com', name: 'Administrator', isAdmin: true, isAnonymous: false, } }); } else if (!adminUser.isAdmin) { adminUser = await this.prisma.user.update({ where: { id: adminUser.id }, data: { isAdmin: true } }); } const payload = { email: adminUser.email, sub: adminUser.id }; return { access_token: this.jwtService.sign(payload), user: { id: adminUser.id, email: adminUser.email, name: adminUser.name, isAdmin: adminUser.isAdmin, }, }; } const user = await this.prisma.user.findUnique({ where: { email } }); if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) { throw new common_1.UnauthorizedException('Email hoặc mật khẩu không chính xác'); } if (user.isBlocked) { throw new common_1.UnauthorizedException('Tài khoản của bạn đã bị khóa. Vui lòng liên hệ quản trị viên.'); } const payload = { email: user.email, sub: user.id }; return { access_token: this.jwtService.sign(payload), user: { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin, }, }; } async googleLogin(body) { const { credential } = body; if (!credential) { throw new common_1.BadRequestException('Vui lòng cung cấp Google credential.'); } try { const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`); if (!response.ok) { throw new common_1.UnauthorizedException('Token Google không hợp lệ hoặc đã hết hạn.'); } const payload = await response.json(); const email = payload.email; const name = payload.name || email.split('@')[0]; const avatar = payload.picture || null; if (!email) { throw new common_1.BadRequestException('Không tìm thấy địa chỉ email từ Google.'); } let user = await this.prisma.user.findUnique({ where: { email } }); if (!user) { user = await this.prisma.user.create({ data: { email, name, avatar, isAnonymous: false, }, }); } else if (user.isBlocked) { throw new common_1.UnauthorizedException('Tài khoản của bạn đã bị khóa. Vui lòng liên hệ quản trị viên.'); } else if (user.isAnonymous) { user = await this.prisma.user.update({ where: { id: user.id }, data: { isAnonymous: false, name: user.name || name, avatar: user.avatar || avatar, }, }); } const jwtPayload = { email: user.email, sub: user.id }; return { access_token: this.jwtService.sign(jwtPayload), user: { id: user.id, email: user.email, name: user.name, avatar: user.avatar, isAdmin: user.isAdmin, isGoogle: true, }, }; } catch (error) { console.error('[Google Auth Error]:', error); if (error instanceof common_1.UnauthorizedException || error instanceof common_1.BadRequestException) { throw error; } throw new common_1.BadRequestException('Đã xảy ra lỗi khi đăng nhập bằng Google.'); } } async signupRequest(body) { const { email, password, name, phone, address } = body; const existingUser = await this.prisma.user.findUnique({ where: { email } }); if (existingUser) throw new common_1.BadRequestException('Email đã được sử dụng'); const otp = Math.floor(100000 + Math.random() * 900000).toString(); await this.cacheManager.set(`signup_otp:${email}`, otp, 300000); await this.cacheManager.set(`signup_data:${email}`, JSON.stringify({ password, name, phone, address }), 300000); try { await this.emailService.sendOTP(email, otp); return { success: true, message: 'Mã OTP đã được gửi đến email của bạn.' }; } catch (error) { console.error('[Signup OTP] Error:', error); throw new common_1.BadRequestException('Không thể gửi mã xác thực tới email này. Vui lòng kiểm tra lại cấu hình SMTP.'); } } async signupVerify(body) { const { email, otp, guestId } = body; const storedOtp = await this.cacheManager.get(`signup_otp:${email}`); if (!storedOtp || storedOtp !== otp) { throw new common_1.BadRequestException('Mã OTP không chính xác hoặc đã hết hạn.'); } const cachedDataStr = await this.cacheManager.get(`signup_data:${email}`); if (!cachedDataStr) { throw new common_1.BadRequestException('Thông tin đăng ký đã hết hạn. Vui lòng thực hiện lại từ đầu.'); } const { password, name, phone, address } = JSON.parse(cachedDataStr); const passwordHash = await bcrypt.hash(password, 10); let user; if (guestId) { const guestUser = await this.prisma.user.findUnique({ where: { id: guestId } }); if (guestUser && guestUser.isAnonymous) { const existingUser = await this.prisma.user.findFirst({ where: { email, isAnonymous: false } }); if (existingUser) { throw new common_1.BadRequestException('Email này đã được đăng ký bởi tài khoản khác.'); } user = await this.prisma.user.update({ where: { id: guestId }, data: { email, passwordHash, name, phone, address, isAnonymous: false, }, select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true } }); } } if (!user) { const userCount = await this.prisma.user.count(); const shouldBeAdmin = userCount === 0; user = await this.prisma.user.create({ data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin }, select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true } }); } await Promise.all([ this.cacheManager.del(`signup_otp:${email}`), this.cacheManager.del(`signup_data:${email}`) ]); return user; } }; __decorate([ (0, common_1.Post)('convert-guest'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AuthController.prototype, "convertGuestToOfficial", null); __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Post)('promote-admin'), __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) ], AuthController.prototype, "promoteAdmin", null); __decorate([ (0, common_1.Post)('create-guest'), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AuthController.prototype, "createGuestUser", null); __decorate([ (0, common_1.Get)('status'), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AuthController.prototype, "getStatus", null); __decorate([ (0, common_1.Post)('login'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AuthController.prototype, "login", null); __decorate([ (0, common_1.Post)('google'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AuthController.prototype, "googleLogin", null); __decorate([ (0, common_1.Post)('signup/request'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AuthController.prototype, "signupRequest", null); __decorate([ (0, common_1.Post)('signup/verify'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AuthController.prototype, "signupVerify", null); AuthController = __decorate([ (0, common_1.Controller)('auth'), __param(4, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)), __metadata("design:paramtypes", [prisma_service_1.PrismaService, jwt_1.JwtService, config_1.ConfigService, EmailService, Object]) ], AuthController); let PublicTourController = class PublicTourController { constructor(prisma) { this.prisma = prisma; } async getPublicTourDetails(id) { console.log(`[PublicTour] Đang truy vấn chi tiết Tour ID: ${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) { console.warn(`[PublicTour] Không tìm thấy Tour với ID: ${id} hoặc Tour đã bị xóa`); throw new common_1.NotFoundException(`Không tìm thấy Tour`); } return tour; } }; __decorate([ (0, common_1.UseInterceptors)(compress_cache_interceptor_1.CompressCacheInterceptor), (0, common_1.Get)(':id/public'), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], PublicTourController.prototype, "getPublicTourDetails", null); PublicTourController = __decorate([ (0, common_1.Controller)('tours'), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], PublicTourController); let TourController = class TourController { constructor(prisma, emailService, cacheManager, commentGateway) { this.prisma = prisma; this.emailService = emailService; this.cacheManager = cacheManager; this.commentGateway = commentGateway; } async createTour(body, req) { const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags, members } = body; const filteredTitle = await filterText(this.prisma, title || ''); const filteredDesc = await filterText(this.prisma, description || ''); const tour = await this.prisma.tour.create({ data: { title: filteredTitle, description: filteredDesc, startDate: startDate ? new Date(startDate) : null, endDate: endDate ? new Date(endDate) : null, tags: tags || [], adultCount: adultCount || 1, childCount: childCount || 0, childDiscount: childDiscount || 0, createdById: req.user.id, participants: { create: [ { userId: req.user.id, role: 'OWNER' }, ...(members && Array.isArray(members) ? members.map((m) => ({ userId: m.userId || null, displayName: m.displayName || null, role: m.role || 'MEMBER' })) : []) ] }, legs: { create: { sequence: 1, note: 'Chặng khởi đầu' } } }, include: { participants: { include: { user: { select: { id: true, name: true, email: true } } } } } }); const noteContent = `

${filteredTitle}

Ghi chú chung

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 = ` ${title}

Đang chuyển hướng bạn đến YoTrip...

Nếu trang không tự động tải, nhấn vào đây.

`; res.type('text/html').send(htmlContent); } async flagPhoto(photoId, body, req) { const photo = await this.prisma.photo.findUnique({ where: { id: photoId } }); if (!photo) { throw new common_1.NotFoundException('Ảnh không tồn tại'); } const updatedPhoto = await this.prisma.photo.update({ where: { id: photoId }, data: { isFlagged: true, flaggedReason: body.reason || 'Không xác định', flaggedAt: new Date() } }); return { success: true, message: 'Đã báo cáo ảnh thành công', photo: updatedPhoto }; } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], PublicPhotoController.prototype, "getPublicPhotos", null); __decorate([ (0, common_1.Get)(':photoId/comments'), __param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], PublicPhotoController.prototype, "getPhotoComments", null); __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Post)(':photoId/comments'), __param(0, (0, common_1.Param)('photoId', 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) ], PublicPhotoController.prototype, "addPhotoComment", null); __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Delete)('comments/: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) ], PublicPhotoController.prototype, "deletePhotoComment", null); __decorate([ (0, common_1.Get)(':photoId/share'), __param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __param(2, (0, common_1.Res)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], PublicPhotoController.prototype, "sharePhoto", null); __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Post)(':photoId/flag'), __param(0, (0, common_1.Param)('photoId', 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) ], PublicPhotoController.prototype, "flagPhoto", null); PublicPhotoController = __decorate([ (0, common_1.Controller)('public-photos'), __metadata("design:paramtypes", [prisma_service_1.PrismaService, CommentGateway]) ], PublicPhotoController); let ConnectionController = class ConnectionController { constructor(prisma, commentGateway) { this.prisma = prisma; this.commentGateway = commentGateway; } async getConnections(req) { const currentUserId = req.user.id; const connections = await this.prisma.userConnection.findMany({ where: { OR: [ { requesterId: currentUserId }, { receiverId: currentUserId } ], status: 'ACCEPTED' }, include: { requester: { select: { id: true, name: true, email: true, avatar: true } }, receiver: { select: { id: true, name: true, email: true, avatar: true } } } }); const formattedConnections = connections.map(conn => { const isRequester = conn.requesterId === currentUserId; const targetUser = isRequester ? conn.receiver : conn.requester; return { id: conn.id, targetUser, type: conn.type, status: conn.status, createdAt: conn.createdAt }; }); const receivedRequests = await this.prisma.userConnection.findMany({ where: { receiverId: currentUserId, status: 'PENDING' }, include: { requester: { select: { id: true, name: true, email: true, avatar: true } } } }); const sentRequests = await this.prisma.userConnection.findMany({ where: { requesterId: currentUserId, status: 'PENDING' }, include: { receiver: { select: { id: true, name: true, email: true, avatar: true } } } }); return { connections: formattedConnections, receivedRequests: receivedRequests.map(r => ({ id: r.id, requester: r.requester, type: r.type, createdAt: r.createdAt })), sentRequests: sentRequests.map(r => ({ id: r.id, receiver: r.receiver, type: r.type, createdAt: r.createdAt })) }; } async sendRequest(req, body) { const requesterId = req.user.id; const { receiverId } = body; if (requesterId === receiverId) { throw new common_1.BadRequestException('Bạn không thể gửi lời mời kết nối cho chính mình.'); } const receiver = await this.prisma.user.findUnique({ where: { id: receiverId } }); if (!receiver) { throw new common_1.NotFoundException('Không tìm thấy người nhận.'); } const existing = await this.prisma.userConnection.findFirst({ where: { OR: [ { requesterId, receiverId }, { requesterId: receiverId, receiverId } ] } }); if (existing) { if (existing.status === 'ACCEPTED') { throw new common_1.BadRequestException('Hai bạn đã kết nối với nhau.'); } if (existing.status === 'PENDING') { throw new common_1.BadRequestException('Yêu cầu kết nối đang chờ xử lý.'); } await this.prisma.userConnection.delete({ where: { id: existing.id } }); } const newConn = await this.prisma.userConnection.create({ data: { requesterId, receiverId, status: 'PENDING', type: 'FRIEND' } }); return { success: true, connection: newConn }; } async updateRequest(id, body, req) { const currentUserId = req.user.id; const conn = await this.prisma.userConnection.findUnique({ where: { id } }); if (!conn) { throw new common_1.NotFoundException('Không tìm thấy bản ghi kết nối.'); } if (body.status) { if (conn.receiverId !== currentUserId) { throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này.'); } const updated = await this.prisma.userConnection.update({ where: { id }, data: { status: body.status } }); if (body.status === 'ACCEPTED') { const receiverUser = await this.prisma.user.findUnique({ where: { id: currentUserId }, select: { name: true } }); this.commentGateway.notifyConnectionAccepted(conn.requesterId, { connectionId: conn.id, acceptedByName: receiverUser?.name || 'Ai đó', acceptedById: currentUserId }); } return { success: true, connection: updated }; } if (body.type) { if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) { throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này.'); } if (conn.status !== 'ACCEPTED') { throw new common_1.BadRequestException('Chỉ có thể thay đổi phân nhóm sau khi đã chấp nhận kết nối.'); } const updated = await this.prisma.userConnection.update({ where: { id }, data: { type: body.type } }); return { success: true, connection: updated }; } throw new common_1.BadRequestException('Yêu cầu không hợp lệ.'); } async deleteConnection(id, req) { const currentUserId = req.user.id; const conn = await this.prisma.userConnection.findUnique({ where: { id } }); if (!conn) { throw new common_1.NotFoundException('Không tìm thấy bản ghi kết nối.'); } if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) { throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này.'); } await this.prisma.userConnection.delete({ where: { id } }); return { success: true, message: 'Đã hủy kết nối thành công.' }; } }; __decorate([ (0, common_1.Get)(), __param(0, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], ConnectionController.prototype, "getConnections", null); __decorate([ (0, common_1.Post)(), __param(0, (0, common_1.Req)()), __param(1, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Object]), __metadata("design:returntype", Promise) ], ConnectionController.prototype, "sendRequest", 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) ], ConnectionController.prototype, "updateRequest", 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) ], ConnectionController.prototype, "deleteConnection", null); ConnectionController = __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Controller)('connections'), __metadata("design:paramtypes", [prisma_service_1.PrismaService, CommentGateway]) ], ConnectionController); let DirectMessageController = class DirectMessageController { constructor(prisma, commentGateway) { this.prisma = prisma; this.commentGateway = commentGateway; } async getMessages(userId, req) { const currentUserId = req.user.id; const conn = await this.prisma.userConnection.findFirst({ where: { OR: [ { requesterId: currentUserId, receiverId: userId }, { requesterId: userId, receiverId: currentUserId } ], status: 'ACCEPTED' } }); if (!conn) { throw new common_1.ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.'); } const messages = await this.prisma.directMessage.findMany({ where: { OR: [ { senderId: currentUserId, receiverId: userId }, { senderId: userId, receiverId: currentUserId } ] }, orderBy: { createdAt: 'asc' } }); return messages; } async sendMessage(req, body) { const senderId = req.user.id; const { receiverId, content, attachmentUrl, latitude, longitude } = body; if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) { throw new common_1.BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.'); } const conn = await this.prisma.userConnection.findFirst({ where: { OR: [ { requesterId: senderId, receiverId: receiverId }, { requesterId: receiverId, receiverId: senderId } ], status: 'ACCEPTED' } }); if (!conn) { throw new common_1.ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.'); } const filteredContent = content ? await filterText(this.prisma, content) : ''; const message = await this.prisma.directMessage.create({ data: { senderId, receiverId, content: filteredContent, attachmentUrl, latitude, longitude }, include: { sender: { select: { id: true, name: true, avatar: true } } } }); this.commentGateway.notifyNewMessage(receiverId, message); return message; } }; __decorate([ (0, common_1.Get)(':userId'), __param(0, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object]), __metadata("design:returntype", Promise) ], DirectMessageController.prototype, "getMessages", null); __decorate([ (0, common_1.Post)(), __param(0, (0, common_1.Req)()), __param(1, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object, Object]), __metadata("design:returntype", Promise) ], DirectMessageController.prototype, "sendMessage", null); DirectMessageController = __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Controller)('messages'), __metadata("design:paramtypes", [prisma_service_1.PrismaService, CommentGateway]) ], DirectMessageController); let UploadController = class UploadController { async uploadAttachment(files) { if (!files || files.length === 0) { throw new common_1.BadRequestException('Vui lòng chọn ảnh.'); } const file = files[0]; const attachmentsDir = path.join(UPLOAD_ROOT, 'attachments'); if (!fs.existsSync(attachmentsDir)) { fs.mkdirSync(attachmentsDir, { recursive: true }); } const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const extension = path.extname(file.originalname).toLowerCase() || '.jpg'; const filename = `${uniqueSuffix}${extension}`; const filePath = path.join(attachmentsDir, filename); fs.writeFileSync(filePath, file.buffer); return { url: `/uploads/attachments/${filename}` }; } }; __decorate([ (0, common_1.Post)(), (0, common_1.UseInterceptors)((0, platform_express_1.FilesInterceptor)('image', 1)), __param(0, (0, common_1.UploadedFiles)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Array]), __metadata("design:returntype", Promise) ], UploadController.prototype, "uploadAttachment", null); UploadController = __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Controller)('upload') ], UploadController); let TourMessageController = class TourMessageController { constructor(prisma, commentGateway) { this.prisma = prisma; this.commentGateway = commentGateway; } async getTourMessages(tourId, req) { const userId = req.user.id; const participant = await this.prisma.tourParticipant.findFirst({ where: { tourId, userId } }); if (!participant) { throw new common_1.ForbiddenException('Bạn không phải là thành viên của hành trình này.'); } const messages = await this.prisma.tourMessage.findMany({ where: { tourId }, include: { sender: { select: { id: true, name: true, avatar: true } } }, orderBy: { createdAt: 'asc' } }); return messages; } async sendTourMessage(tourId, req, body) { const senderId = req.user.id; const { content, attachmentUrl, latitude, longitude, taggedUserIds } = body; console.log(`[sendTourMessage] senderId=${senderId}, tourId=${tourId}, taggedUserIds=`, taggedUserIds); if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) { throw new common_1.BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.'); } const participant = await this.prisma.tourParticipant.findFirst({ where: { tourId, userId: senderId } }); if (!participant) { throw new common_1.ForbiddenException('Bạn không phải là thành viên của hành trình này.'); } const filteredContent = content ? await filterText(this.prisma, content) : ''; const tourMessage = await this.prisma.tourMessage.create({ data: { tourId, senderId, content: filteredContent, attachmentUrl, latitude, longitude }, include: { sender: { select: { id: true, name: true, avatar: true } } } }); this.commentGateway.server.to(`tour_${tourId}`).emit('tourMessageReceived', { tourId, message: tourMessage }); const hasTags = Array.isArray(taggedUserIds) && taggedUserIds.length > 0; const otherParticipants = await this.prisma.tourParticipant.findMany({ where: { tourId, userId: { not: senderId, in: hasTags ? taggedUserIds : undefined }, NOT: { userId: null } } }); console.log(`[sendTourMessage] otherParticipants found count: ${otherParticipants.length}`, otherParticipants.map(o => o.userId)); const tour = await this.prisma.tour.findUnique({ where: { id: tourId }, select: { title: true } }); for (const p of otherParticipants) { if (p.userId) { const filteredContent = content ? await filterText(this.prisma, content) : ''; const isTagged = hasTags && taggedUserIds.includes(p.userId); console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`); this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', { tourId, senderName: req.user.name || 'Thành viên', tourTitle: tour?.title || 'Hành trình', content: isTagged ? `@Bạn: ${filteredContent || ''}` : filteredContent || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'), isTagged }); } } return tourMessage; } }; __decorate([ (0, common_1.Get)(), __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) ], TourMessageController.prototype, "getTourMessages", null); __decorate([ (0, common_1.Post)(), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __param(1, (0, common_1.Req)()), __param(2, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, Object, Object]), __metadata("design:returntype", Promise) ], TourMessageController.prototype, "sendTourMessage", null); TourMessageController = __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Controller)('tours/:tourId/messages'), __metadata("design:paramtypes", [prisma_service_1.PrismaService, CommentGateway]) ], TourMessageController); async function filterText(prisma, text) { if (!text) return text; try { const filters = await prisma.wordFilter.findMany(); let result = text; for (const filter of filters) { const escapedWord = filter.word.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); const regex = new RegExp(escapedWord, 'gi'); result = result.replace(regex, filter.replacement); } return result; } catch (err) { console.error('Lỗi khi lọc từ cấm:', err); return text; } } let ModerationController = class ModerationController { constructor(prisma) { this.prisma = prisma; } async getSettings() { let settings = await this.prisma.moderationSetting.findFirst(); if (!settings) { settings = await this.prisma.moderationSetting.create({ data: { blockNsfw: false, blurFaces: false } }); } return settings; } }; __decorate([ (0, common_1.Get)('settings'), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], ModerationController.prototype, "getSettings", null); ModerationController = __decorate([ (0, common_1.Controller)('moderation'), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], ModerationController); let AdminModerationController = class AdminModerationController { constructor(prisma) { this.prisma = prisma; } async getSettings() { let settings = await this.prisma.moderationSetting.findFirst(); if (!settings) { settings = await this.prisma.moderationSetting.create({ data: { blockNsfw: false, blurFaces: false } }); } return settings; } async updateSettings(body) { let settings = await this.prisma.moderationSetting.findFirst(); if (!settings) { return this.prisma.moderationSetting.create({ data: { blockNsfw: body.blockNsfw ?? false, blurFaces: body.blurFaces ?? false } }); } return this.prisma.moderationSetting.update({ where: { id: settings.id }, data: { blockNsfw: body.blockNsfw, blurFaces: body.blurFaces } }); } async getWordFilters() { return this.prisma.wordFilter.findMany({ orderBy: { createdAt: 'desc' } }); } async addWordFilter(body) { const { word, replacement } = body; if (!word || replacement === undefined) { throw new common_1.BadRequestException('Từ khóa và từ thay thế không được để trống.'); } return this.prisma.wordFilter.create({ data: { word: word.trim(), replacement: replacement.trim() } }); } async deleteWordFilter(id) { await this.prisma.wordFilter.delete({ where: { id } }); return { success: true }; } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminModerationController.prototype, "getSettings", null); __decorate([ (0, common_1.Post)(), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AdminModerationController.prototype, "updateSettings", null); __decorate([ (0, common_1.Get)('word-filters'), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminModerationController.prototype, "getWordFilters", null); __decorate([ (0, common_1.Post)('word-filters'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AdminModerationController.prototype, "addWordFilter", null); __decorate([ (0, common_1.Delete)('word-filters/:id'), __param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], AdminModerationController.prototype, "deleteWordFilter", null); AdminModerationController = __decorate([ (0, common_1.Controller)('admin/moderation'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], AdminModerationController); let AdminPhotosController = class AdminPhotosController { constructor(prisma) { this.prisma = prisma; } async getFlaggedPhotos() { return this.prisma.photo.findMany({ where: { isFlagged: true }, include: { uploader: { select: { id: true, name: true, email: true } }, tour: { select: { id: true, title: true } } }, orderBy: { flaggedAt: 'desc' } }); } async approvePhoto(photoId) { const photo = await this.prisma.photo.findUnique({ where: { id: photoId } }); if (!photo) { throw new common_1.NotFoundException('Ảnh không tồn tại'); } const updatedPhoto = await this.prisma.photo.update({ where: { id: photoId }, data: { isFlagged: false, flaggedReason: null, flaggedAt: null } }); return { success: true, message: 'Đã duyệt ảnh thành công', photo: updatedPhoto }; } async deletePhoto(photoId) { const photo = await this.prisma.photo.findUnique({ where: { id: photoId } }); if (!photo) { throw new common_1.NotFoundException('Ảnh không tồn tại'); } const deletedPhoto = await this.prisma.photo.update({ where: { id: photoId }, data: { isDeleted: true, deletedAt: new Date() } }); return { success: true, message: 'Đã xóa ảnh thành công', photo: deletedPhoto }; } }; __decorate([ (0, common_1.Get)('flagged'), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminPhotosController.prototype, "getFlaggedPhotos", null); __decorate([ (0, common_1.Post)(':photoId/approve'), __param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], AdminPhotosController.prototype, "approvePhoto", null); __decorate([ (0, common_1.Delete)(':photoId'), __param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], AdminPhotosController.prototype, "deletePhoto", null); AdminPhotosController = __decorate([ (0, common_1.Controller)('admin/photos'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], AdminPhotosController); let ReportsController = class ReportsController { constructor(prisma) { this.prisma = prisma; } async createReport(body) { const { type, name, phone, email, address, latitude, longitude, reason } = body; if (!type || !name || !reason) { throw new common_1.BadRequestException('Loại báo cáo, tên cơ sở và lý do không được để trống.'); } const lat = latitude ? parseFloat(latitude) : null; const lng = longitude ? parseFloat(longitude) : null; return this.prisma.businessReport.create({ data: { type: type.trim(), name: name.trim(), phone: phone ? phone.trim() : null, email: email ? email.trim() : null, address: address ? address.trim() : null, latitude: lat, longitude: lng, reason: reason.trim(), isBlacklisted: false, } }); } async getBlacklist() { return this.prisma.businessReport.findMany({ where: { isBlacklisted: true }, orderBy: { createdAt: 'desc' } }); } }; __decorate([ (0, common_1.Post)(), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], ReportsController.prototype, "createReport", null); __decorate([ (0, common_1.Get)('blacklist'), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], ReportsController.prototype, "getBlacklist", null); ReportsController = __decorate([ (0, common_1.Controller)('reports'), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], ReportsController); let AdminReportsController = class AdminReportsController { constructor(prisma) { this.prisma = prisma; } async getAllReports() { return this.prisma.businessReport.findMany({ orderBy: { createdAt: 'desc' } }); } async toggleBlacklist(id, body) { const report = await this.prisma.businessReport.findUnique({ where: { id } }); if (!report) { throw new common_1.NotFoundException('Không tìm thấy báo cáo.'); } return this.prisma.businessReport.update({ where: { id }, data: { isBlacklisted: body.isBlacklisted } }); } async deleteReport(id) { const report = await this.prisma.businessReport.findUnique({ where: { id } }); if (!report) { throw new common_1.NotFoundException('Không tìm thấy báo cáo.'); } await this.prisma.businessReport.delete({ where: { id } }); return { success: true }; } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminReportsController.prototype, "getAllReports", null); __decorate([ (0, common_1.Patch)(':id/blacklist'), __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) ], AdminReportsController.prototype, "toggleBlacklist", null); __decorate([ (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) ], AdminReportsController.prototype, "deleteReport", null); AdminReportsController = __decorate([ (0, common_1.Controller)('admin/reports'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], AdminReportsController); let TrustedUsersController = class TrustedUsersController { constructor(prisma) { this.prisma = prisma; } async getTrustedUsers() { const ratings = await this.prisma.tourRating.groupBy({ by: ['targetUserId'], _avg: { averageScore: true }, _count: { id: true } }); const users = await this.prisma.user.findMany({ where: { id: { in: ratings.map(r => r.targetUserId) } }, select: { id: true, name: true, avatar: true } }); const result = ratings.map(r => { const u = users.find(user => user.id === r.targetUserId); return { id: r.targetUserId, name: u?.name || 'Ẩn danh', avatar: u?.avatar || null, averageScore: Math.round((r._avg.averageScore || 0) * 10) / 10, ratingCount: r._count.id }; }); return result.sort((a, b) => b.averageScore - a.averageScore || b.ratingCount - a.ratingCount); } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], TrustedUsersController.prototype, "getTrustedUsers", null); TrustedUsersController = __decorate([ (0, common_1.Controller)('users/trusted'), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], TrustedUsersController); let TourRatingController = class TourRatingController { constructor(prisma) { this.prisma = prisma; } async getTourRatings(tourId) { return this.prisma.tourRating.findMany({ where: { tourId }, include: { raterUser: { select: { id: true, name: true, avatar: true } }, targetUser: { select: { id: true, name: true, avatar: true } } }, orderBy: { createdAt: 'desc' } }); } async createTourRating(tourId, body, req) { const raterUserId = req.user.id; const { targetUserId, honesty, transparency, enthusiasm, cheerfulness, seriousness, planning, survival, comment } = body; if (!targetUserId || honesty < 1 || honesty > 5 || transparency < 1 || transparency > 5 || enthusiasm < 1 || enthusiasm > 5 || cheerfulness < 1 || cheerfulness > 5 || seriousness < 1 || seriousness > 5 || planning < 1 || planning > 5 || survival < 1 || survival > 5) { throw new common_1.BadRequestException('Dữ liệu đánh giá không hợp lệ.'); } const participant = await this.prisma.tourParticipant.findFirst({ where: { tourId, userId: raterUserId } }); if (!participant) { throw new common_1.ForbiddenException('Bạn không phải là thành viên của tour này.'); } const targetParticipant = await this.prisma.tourParticipant.findFirst({ where: { tourId, userId: targetUserId, role: { in: ['OWNER', 'MANAGER'] } } }); if (!targetParticipant) { throw new common_1.BadRequestException('Chỉ có thể đánh giá người tạo tour hoặc người quản lý.'); } const averageScore = (honesty + transparency + enthusiasm + cheerfulness + seriousness + planning + survival) / 7; const existingRating = await this.prisma.tourRating.findUnique({ where: { tourId_targetUserId_raterUserId: { tourId, targetUserId, raterUserId } } }); if (existingRating) { return this.prisma.tourRating.update({ where: { id: existingRating.id }, data: { honesty, transparency, enthusiasm, cheerfulness, seriousness, planning, survival, averageScore, comment, createdAt: new Date() } }); } return this.prisma.tourRating.create({ data: { tourId, targetUserId, raterUserId, honesty, transparency, enthusiasm, cheerfulness, seriousness, planning, survival, averageScore, comment } }); } }; __decorate([ (0, common_1.Get)(), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], TourRatingController.prototype, "getTourRatings", null); __decorate([ (0, common_1.Post)(), __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) ], TourRatingController.prototype, "createTourRating", null); TourRatingController = __decorate([ (0, common_1.Controller)('tours/:tourId/ratings'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], TourRatingController); let PublicTourShareController = class PublicTourShareController { constructor(prisma) { this.prisma = prisma; } async getSharedJourney(token) { const tourShare = await this.prisma.tourShare.findUnique({ where: { token, isEnabled: true }, include: { tour: { include: { creator: { select: { id: true, name: true, phone: true } }, participants: { select: { id: true, role: true, displayName: true, user: { select: { id: true, name: true, phone: true } } } }, legs: { include: { locations: { orderBy: { plannedStart: 'asc' } } }, orderBy: { sequence: 'asc' } } } } } }); if (!tourShare || !tourShare.tour) { throw new common_1.NotFoundException('Không tìm thấy hành trình chia sẻ hoặc liên kết đã bị vô hiệu hóa.'); } return { tour: tourShare.tour, isEnabled: tourShare.isEnabled }; } }; __decorate([ (0, common_1.Get)('share/:token'), __param(0, (0, common_1.Param)('token')), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], PublicTourShareController.prototype, "getSharedJourney", null); PublicTourShareController = __decorate([ (0, common_1.Controller)('tours'), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], PublicTourShareController); let TourShareController = class TourShareController { constructor(prisma) { this.prisma = prisma; } async getShareStatus(tourId) { let share = await this.prisma.tourShare.findUnique({ where: { tourId } }); if (!share) { share = await this.prisma.tourShare.create({ data: { tourId, isEnabled: false } }); } return share; } async toggleShare(tourId, body) { let share = await this.prisma.tourShare.findUnique({ where: { tourId } }); if (!share) { return this.prisma.tourShare.create({ data: { tourId, isEnabled: body.isEnabled } }); } return this.prisma.tourShare.update({ where: { id: share.id }, data: { isEnabled: body.isEnabled } }); } }; __decorate([ (0, common_1.Get)(), __param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], TourShareController.prototype, "getShareStatus", null); __decorate([ (0, common_1.Post)(), __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) ], TourShareController.prototype, "toggleShare", null); TourShareController = __decorate([ (0, common_1.Controller)('tours/:tourId/share'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], TourShareController); let TourNoteController = class TourNoteController { constructor(prisma) { this.prisma = prisma; } async getNotes(tourId) { return this.prisma.tourNote.findMany({ where: { tourId, isDeleted: false }, orderBy: { createdAt: 'desc' } }); } async createNote(tourId, body, req) { const { title, content } = body; const filteredTitle = await filterText(this.prisma, title || 'Ghi chú không tiêu đề'); const filteredContent = await filterText(this.prisma, content || ''); return this.prisma.tourNote.create({ data: { tourId, userId: req.user.id, title: filteredTitle, content: filteredContent, } }); } async updateNote(tourId, noteId, body, req) { const { title, content } = body; const note = await this.prisma.tourNote.findUnique({ where: { id: noteId } }); if (!note || note.tourId !== tourId || note.isDeleted) { throw new common_1.NotFoundException('Không tìm thấy ghi chú'); } if (note.userId !== req.user.id && !req.user.isAdmin) { throw new common_1.ForbiddenException('Bạn không có quyền sửa ghi chú này'); } const updateData = {}; if (title !== undefined) updateData.title = await filterText(this.prisma, title); if (content !== undefined) updateData.content = await filterText(this.prisma, content); return this.prisma.tourNote.update({ where: { id: noteId }, data: updateData }); } async deleteNote(tourId, noteId, req) { const note = await this.prisma.tourNote.findUnique({ where: { id: noteId } }); if (!note || note.tourId !== tourId || note.isDeleted) { throw new common_1.NotFoundException('Không tìm thấy ghi chú'); } if (note.userId !== req.user.id && !req.user.isAdmin) { throw new common_1.ForbiddenException('Bạn không có quyền xóa ghi chú này'); } await this.prisma.tourNote.update({ where: { id: noteId }, data: { isDeleted: true, deletedAt: new Date() } }); return { success: true }; } async insertSection(tourId, body, req) { const tour = await this.prisma.tour.findUnique({ where: { id: tourId } }); if (!tour) throw new common_1.NotFoundException('Không tìm thấy tour'); const masterNote = await this.prisma.tourNote.findFirst({ where: { tourId, title: `Ghi chú: ${tour.title}`, isDeleted: false, userId: req.user.id } }); let note = masterNote; if (!note) { note = await this.prisma.tourNote.create({ data: { tourId, userId: req.user.id, title: `Ghi chú: ${tour.title}`, content: `# ${tour.title}\n\n## Ghi chú chung\n\n*(Nội dung ghi chú tổng quan của chuyến đi...)*\n\n` } }); } const leg = await this.prisma.leg.findUnique({ where: { id: body.legId } }); const stageHeader = leg ? `

Ghi chú: ${leg.note || `Chặng ${leg.sequence}`}

` : '

Ghi chú:

'; let content = note.content; const stageIndex = content.indexOf(stageHeader); if (stageIndex === -1) { content += `

${stageHeader}${body.noteSnippet}`; } else { const nextHeaderIndex = content.indexOf('

', stageIndex + stageHeader.length); if (nextHeaderIndex !== -1) { content = content.slice(0, nextHeaderIndex) + body.noteSnippet + content.slice(nextHeaderIndex); } else { content += body.noteSnippet; } } return this.prisma.tourNote.update({ where: { id: note.id }, data: { content } }); } }; __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.Get)(), __param(0, (0, common_1.Param)('tourId')), __metadata("design:type", Function), __metadata("design:paramtypes", [String]), __metadata("design:returntype", Promise) ], TourNoteController.prototype, "getNotes", 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.Post)(), __param(0, (0, common_1.Param)('tourId')), __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) ], TourNoteController.prototype, "createNote", 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.Put)(':noteId'), __param(0, (0, common_1.Param)('tourId')), __param(1, (0, common_1.Param)('noteId')), __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) ], TourNoteController.prototype, "updateNote", 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.Delete)(':noteId'), __param(0, (0, common_1.Param)('tourId')), __param(1, (0, common_1.Param)('noteId')), __param(2, (0, common_1.Req)()), __metadata("design:type", Function), __metadata("design:paramtypes", [String, String, Object]), __metadata("design:returntype", Promise) ], TourNoteController.prototype, "deleteNote", 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.Post)('insert'), __param(0, (0, common_1.Param)('tourId')), __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) ], TourNoteController.prototype, "insertSection", null); TourNoteController = __decorate([ (0, common_1.Controller)('tours/:tourId/notes'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], TourNoteController); let AdminNoteController = class AdminNoteController { constructor(prisma) { this.prisma = prisma; } async getAllActiveNotes() { return this.prisma.tourNote.findMany({ where: { isDeleted: false }, include: { tour: { select: { title: true } }, user: { select: { name: true, email: true } } }, orderBy: { createdAt: 'desc' } }); } async softDeleteNote(id) { await this.prisma.tourNote.update({ where: { id }, data: { isDeleted: true, deletedAt: new Date() } }); return { success: true }; } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminNoteController.prototype, "getAllActiveNotes", null); __decorate([ (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) ], AdminNoteController.prototype, "softDeleteNote", null); AdminNoteController = __decorate([ (0, common_1.Controller)('admin/notes'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], AdminNoteController); let AdminTourController = class AdminTourController { constructor(prisma) { this.prisma = prisma; } async getAllActiveTours() { return this.prisma.tour.findMany({ where: { isDeleted: false }, include: { creator: { select: { name: true, email: true } }, _count: { select: { participants: true } } }, orderBy: { createdAt: 'desc' } }); } async softDeleteTour(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() } }); return { success: true }; } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminTourController.prototype, "getAllActiveTours", null); __decorate([ (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) ], AdminTourController.prototype, "softDeleteTour", null); AdminTourController = __decorate([ (0, common_1.Controller)('admin/tours'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], AdminTourController); let RecommendedLocationController = class RecommendedLocationController { constructor(prisma) { this.prisma = prisma; } async getApprovedRecommendations() { return this.prisma.recommendedLocation.findMany({ where: { isApproved: true }, orderBy: { createdAt: 'desc' } }); } async proposeRecommendation(body) { const { type, name, phone, email, address, latitude, longitude, description, stars } = body; if (!name || !type || !description) { throw new common_1.BadRequestException('Vui lòng điền đầy đủ thông tin bắt buộc.'); } const filteredName = await filterText(this.prisma, name); const filteredDesc = await filterText(this.prisma, description); return this.prisma.recommendedLocation.create({ data: { type, name: filteredName, phone, email, address, latitude: latitude ? parseFloat(latitude) : null, longitude: longitude ? parseFloat(longitude) : null, description: filteredDesc, stars: stars ? parseInt(stars) : 5 } }); } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], RecommendedLocationController.prototype, "getApprovedRecommendations", null); __decorate([ (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard), (0, common_1.Post)(), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], RecommendedLocationController.prototype, "proposeRecommendation", null); RecommendedLocationController = __decorate([ (0, common_1.Controller)('recommendations'), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], RecommendedLocationController); let AdminRecommendedLocationController = class AdminRecommendedLocationController { constructor(prisma) { this.prisma = prisma; } async getAllRecommendations() { return this.prisma.recommendedLocation.findMany({ orderBy: { createdAt: 'desc' } }); } async approveRecommendation(id, body) { return this.prisma.recommendedLocation.update({ where: { id }, data: { isApproved: body.isApproved } }); } async deleteRecommendation(id) { await this.prisma.recommendedLocation.delete({ where: { id } }); return { success: true }; } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminRecommendedLocationController.prototype, "getAllRecommendations", null); __decorate([ (0, common_1.Patch)(':id/approve'), __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) ], AdminRecommendedLocationController.prototype, "approveRecommendation", null); __decorate([ (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) ], AdminRecommendedLocationController.prototype, "deleteRecommendation", null); AdminRecommendedLocationController = __decorate([ (0, common_1.Controller)('admin/recommendations'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], AdminRecommendedLocationController); let AdminTrashController = class AdminTrashController { constructor(prisma) { this.prisma = prisma; } async getTrashItems() { const setting = await this.prisma.moderationSetting.findFirst(); const retentionDays = setting?.trashRetentionDays ?? 30; const tours = await this.prisma.tour.findMany({ where: { isDeleted: true }, include: { creator: { select: { name: true } } }, orderBy: { deletedAt: 'desc' } }); const photos = await this.prisma.photo.findMany({ where: { isDeleted: true }, include: { uploader: { select: { name: true } } }, orderBy: { deletedAt: 'desc' } }); const notes = await this.prisma.tourNote.findMany({ where: { isDeleted: true }, include: { user: { select: { name: true } }, tour: { select: { title: true } } }, orderBy: { deletedAt: 'desc' } }); return { retentionDays, tours, photos, notes }; } async updateRetentionDays(body) { const { days } = body; if (days === undefined || days < 1) { throw new common_1.BadRequestException('Số ngày lưu trữ không hợp lệ.'); } const setting = await this.prisma.moderationSetting.findFirst(); if (setting) { return this.prisma.moderationSetting.update({ where: { id: setting.id }, data: { trashRetentionDays: days } }); } else { return this.prisma.moderationSetting.create({ data: { trashRetentionDays: days } }); } } async restoreItems(body) { const { type, ids } = body; if (!type || !ids || !Array.isArray(ids)) { throw new common_1.BadRequestException('Tham số không hợp lệ.'); } if (type === 'tour') { await this.prisma.tour.updateMany({ where: { id: { in: ids } }, data: { isDeleted: false, deletedAt: null } }); await this.prisma.photo.updateMany({ where: { tourId: { in: ids } }, data: { isDeleted: false, deletedAt: null } }); await this.prisma.tourNote.updateMany({ where: { tourId: { in: ids } }, data: { isDeleted: false, deletedAt: null } }); } else if (type === 'photo') { await this.prisma.photo.updateMany({ where: { id: { in: ids } }, data: { isDeleted: false, deletedAt: null } }); } else if (type === 'note') { await this.prisma.tourNote.updateMany({ where: { id: { in: ids } }, data: { isDeleted: false, deletedAt: null } }); } return { success: true }; } async deletePermanentItems(body) { const { type, ids } = body; console.log('[deletePermanent] Request received - type:', type, 'ids count:', ids.length); if (!type || !ids || !Array.isArray(ids)) { throw new common_1.BadRequestException('Tham số không hợp lệ.'); } const deleteResults = { success: 0, failed: 0, errors: [] }; if (type === 'tour') { for (const tourId of ids) { try { console.log('[deletePermanent] Deleting tour:', tourId); const photos = await this.prisma.photo.findMany({ where: { tourId } }); console.log('[deletePermanent] Found', photos.length, 'photos for tour'); for (const p of photos) { if (p.imageUrl) { const displayFilePath = path.join(process.cwd(), p.imageUrl.replace(/^\//, '')); console.log('[deletePermanent] Checking display file:', displayFilePath); if (fs.existsSync(displayFilePath)) { try { fs.unlinkSync(displayFilePath); console.log('[deletePermanent] Deleted display file:', displayFilePath); } catch (e) { console.error('[deletePermanent] Error deleting display file:', e); } } } if (p.originalUrl) { const originalFilePath = path.join(process.cwd(), p.originalUrl.replace(/^\//, '')); console.log('[deletePermanent] Checking original file:', originalFilePath); if (fs.existsSync(originalFilePath)) { try { fs.unlinkSync(originalFilePath); console.log('[deletePermanent] Deleted original file:', originalFilePath); } catch (e) { console.error('[deletePermanent] Error deleting original file:', e); } } } } await this.prisma.tour.delete({ where: { id: tourId } }); console.log('[deletePermanent] Deleted tour from database:', tourId); deleteResults.success++; } catch (e) { console.error('[deletePermanent] Error deleting tour:', tourId, e); deleteResults.failed++; deleteResults.errors.push(`Tour ${tourId}: ${e?.message || 'Unknown error'}`); } } } else if (type === 'photo') { console.log('[deletePermanent] Deleting', ids.length, 'photos'); console.log('[deletePermanent] Photo IDs:', JSON.stringify(ids)); if (!ids || ids.length === 0) { console.warn('[deletePermanent] No photo IDs provided'); return { success: true, deleted: 0, failed: 0 }; } for (const photoId of ids) { try { console.log('[deletePermanent] ========== START Processing photo:', photoId); const photo = await this.prisma.photo.findUnique({ where: { id: photoId } }); if (!photo) { console.warn('[deletePermanent] Photo not found in DB:', photoId); deleteResults.failed++; deleteResults.errors.push(`Photo ${photoId}: not found in database`); console.log('[deletePermanent] ========== SKIP (not found):', photoId); continue; } console.log('[deletePermanent] Found photo:', { id: photo.id, imageUrl: photo.imageUrl, isDeleted: photo.isDeleted, tourId: photo.tourId }); console.log('[deletePermanent] Deleting comments for photo:', photoId); try { const commentResult = await this.prisma.comment.deleteMany({ where: { photoId } }); console.log('[deletePermanent] Deleted', commentResult.count, 'comments for photo'); } catch (e) { console.warn('[deletePermanent] Error deleting comments (continuing):', e?.message); } if (photo.imageUrl) { const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); console.log('[deletePermanent] Checking display file:', displayFilePath); if (fs.existsSync(displayFilePath)) { try { fs.unlinkSync(displayFilePath); console.log('[deletePermanent] ✓ Deleted display file'); } catch (e) { console.error('[deletePermanent] Error deleting display file:', e); } } } if (photo.originalUrl) { const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, '')); console.log('[deletePermanent] Checking original file:', originalFilePath); if (fs.existsSync(originalFilePath)) { try { fs.unlinkSync(originalFilePath); console.log('[deletePermanent] ✓ Deleted original file'); } catch (e) { console.error('[deletePermanent] Error deleting original file:', e); } } } console.log('[deletePermanent] Attempting database delete for photo:', photoId); const result = await this.prisma.photo.delete({ where: { id: photoId } }); console.log('[deletePermanent] ✓ Successfully deleted photo from database:', photoId); deleteResults.success++; console.log('[deletePermanent] ========== SUCCESS:', photoId); } catch (e) { console.error('[deletePermanent] ✗ Error deleting photo:', photoId); console.error('[deletePermanent] Error details:', e?.message || String(e)); deleteResults.failed++; deleteResults.errors.push(`Photo ${photoId}: ${e?.message || 'Unknown error'}`); console.log('[deletePermanent] ========== FAILED:', photoId); } } console.log('[deletePermanent] Photos deletion complete - success:', deleteResults.success, 'failed:', deleteResults.failed); } else if (type === 'note') { console.log('[deletePermanent] Deleting', ids.length, 'notes'); try { const result = await this.prisma.tourNote.deleteMany({ where: { id: { in: ids } } }); console.log('[deletePermanent] Deleted notes from database, count:', result.count); deleteResults.success = result.count; } catch (e) { console.error('[deletePermanent] Error deleting notes:', e); deleteResults.failed = ids.length; deleteResults.errors.push(`Notes deletion: ${e?.message || 'Unknown error'}`); } } console.log('[deletePermanent] Deletion complete - success:', deleteResults.success, 'failed:', deleteResults.failed); if (deleteResults.failed > 0) { console.warn('[deletePermanent] Errors:', deleteResults.errors); } return { success: true, deleted: deleteResults.success, failed: deleteResults.failed, errors: deleteResults.errors.length > 0 ? deleteResults.errors : undefined }; } async emptyAllTrash() { console.log('[emptyAllTrash] Starting to empty all trash'); const deleteResults = { success: 0, failed: 0, errors: [] }; try { console.log('[emptyAllTrash] Finding all deleted photos'); const deletedPhotos = await this.prisma.photo.findMany({ where: { isDeleted: true } }); console.log('[emptyAllTrash] Found', deletedPhotos.length, 'deleted photos'); console.log('[emptyAllTrash] Deleting all comments from deleted photos'); const commentCount = await this.prisma.comment.deleteMany({ where: { photoId: { in: deletedPhotos.map(p => p.id) } } }); console.log('[emptyAllTrash] Deleted', commentCount.count, 'comments'); for (const photo of deletedPhotos) { if (photo.imageUrl) { const filePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); console.log('[emptyAllTrash] Deleted display file:', filePath); } catch (e) { console.error('[emptyAllTrash] Error deleting display file:', e); } } } if (photo.originalUrl) { const filePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, '')); if (fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); console.log('[emptyAllTrash] Deleted original file:', filePath); } catch (e) { console.error('[emptyAllTrash] Error deleting original file:', e); } } } } console.log('[emptyAllTrash] Deleting all', deletedPhotos.length, 'photos from database'); const photoResult = await this.prisma.photo.deleteMany({ where: { isDeleted: true } }); console.log('[emptyAllTrash] Deleted', photoResult.count, 'photos'); deleteResults.success += photoResult.count; console.log('[emptyAllTrash] Deleting all deleted tours'); const tourResult = await this.prisma.tour.deleteMany({ where: { isDeleted: true } }); console.log('[emptyAllTrash] Deleted', tourResult.count, 'tours'); deleteResults.success += tourResult.count; console.log('[emptyAllTrash] Deleting all deleted notes'); const noteResult = await this.prisma.tourNote.deleteMany({ where: { isDeleted: true } }); console.log('[emptyAllTrash] Deleted', noteResult.count, 'notes'); deleteResults.success += noteResult.count; console.log('[emptyAllTrash] Successfully emptied all trash - total deleted:', deleteResults.success); } catch (e) { console.error('[emptyAllTrash] Error emptying trash:', e?.message || e); deleteResults.failed++; deleteResults.errors.push(`Emptying trash: ${e?.message || 'Unknown error'}`); } return { success: true, totalDeleted: deleteResults.success, errors: deleteResults.errors.length > 0 ? deleteResults.errors : undefined }; } }; __decorate([ (0, common_1.Get)(), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminTrashController.prototype, "getTrashItems", null); __decorate([ (0, common_1.Patch)('retention-days'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AdminTrashController.prototype, "updateRetentionDays", null); __decorate([ (0, common_1.Post)('restore'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AdminTrashController.prototype, "restoreItems", null); __decorate([ (0, common_1.Post)('delete-permanent'), __param(0, (0, common_1.Body)()), __metadata("design:type", Function), __metadata("design:paramtypes", [Object]), __metadata("design:returntype", Promise) ], AdminTrashController.prototype, "deletePermanentItems", null); __decorate([ (0, common_1.Post)('empty-all'), __metadata("design:type", Function), __metadata("design:paramtypes", []), __metadata("design:returntype", Promise) ], AdminTrashController.prototype, "emptyAllTrash", null); AdminTrashController = __decorate([ (0, common_1.Controller)('admin/trash'), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard), __metadata("design:paramtypes", [prisma_service_1.PrismaService]) ], AdminTrashController); function startAutoCleanup(prisma) { console.log('🔄 Đang kích hoạt dịch vụ dọn dẹp thùng rác tự động...'); setInterval(async () => { try { const setting = await prisma.moderationSetting.findFirst(); const retentionDays = setting?.trashRetentionDays ?? 30; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - retentionDays); console.log(`[Auto Cleanup] Quét dọn các tài nguyên đã bị xóa trước ngày ${cutoffDate.toISOString()}`); const expiredNotes = await prisma.tourNote.findMany({ where: { isDeleted: true, deletedAt: { lt: cutoffDate } } }); if (expiredNotes.length > 0) { const expiredNoteIds = expiredNotes.map(n => n.id); await prisma.tourNote.deleteMany({ where: { id: { in: expiredNoteIds } } }); console.log(`[Auto Cleanup] Đã xóa vĩnh viễn ${expiredNoteIds.length} ghi chú quá hạn.`); } const expiredPhotos = await prisma.photo.findMany({ where: { isDeleted: true, deletedAt: { lt: cutoffDate } } }); for (const photo of expiredPhotos) { 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 prisma.photo.delete({ where: { id: photo.id } }); } if (expiredPhotos.length > 0) { console.log(`[Auto Cleanup] Đã xóa vĩnh viễn ${expiredPhotos.length} hình ảnh quá hạn.`); } const expiredTours = await prisma.tour.findMany({ where: { isDeleted: true, deletedAt: { lt: cutoffDate } } }); for (const tour of expiredTours) { const tourPhotos = await prisma.photo.findMany({ where: { tourId: tour.id } }); for (const photo of tourPhotos) { 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 prisma.tour.delete({ where: { id: tour.id } }); } if (expiredTours.length > 0) { console.log(`[Auto Cleanup] Đã xóa vĩnh viễn ${expiredTours.length} tour du lịch quá hạn.`); } } catch (error) { console.error('[Auto Cleanup] Lỗi khi dọn dẹp thùng rác:', error); } }, 24 * 60 * 60 * 1000); } let AppModule = class AppModule { }; AppModule = __decorate([ (0, common_1.Module)({ imports: [ config_1.ConfigModule.forRoot({ isGlobal: true, envFilePath: path.resolve(__dirname, '..', '..', '.env'), ignoreEnvFile: process.env.NODE_ENV === 'production', }), cache_manager_1.CacheModule.registerAsync({ isGlobal: true, useFactory: async () => ({ store: await (0, cache_manager_redis_yet_1.redisStore)({ url: process.env.REDIS_URL || 'redis://localhost:6379', ttl: CACHE_TTL.DEFAULT, }), }), }), jwt_1.JwtModule.register({ secret: process.env.JWT_SECRET || 'super-secret', signOptions: { expiresIn: '1d' }, }), ], controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController, ModerationController, AdminModerationController, TrustedUsersController, TourRatingController, PublicTourShareController, TourShareController, ReportsController, AdminReportsController, TourNoteController, AdminNoteController, AdminTourController, RecommendedLocationController, AdminRecommendedLocationController, AdminTrashController], providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService, config_1.ConfigService], exports: [prisma_service_1.PrismaService] }) ], AppModule); bootstrap().catch(err => { if (err.message.includes('DATABASE_URL')) { console.error('❌ Lỗi nghiêm trọng: Biến môi trường DATABASE_URL không được tải. Hãy chắc chắn rằng file .env tồn tại ở thư mục gốc của dự án và chứa giá trị này.'); } console.error('💥 Lỗi khởi động Server:'); console.error(err); process.exit(1); }); //# sourceMappingURL=main.js.map