import * as path from 'path'; import * as fs from 'fs'; import * as crypto from 'crypto'; import { ConfigModule, ConfigService } from '@nestjs/config'; import 'reflect-metadata'; import * as zlib from 'zlib'; import { promisify } from 'util'; import sharp from 'sharp'; import exifr from 'exifr'; import heicConvert from 'heic-convert'; import { NestFactory } from '@nestjs/core'; import { Module, Controller, Get, Post, Put, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Res, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common'; import { NestExpressApplication } from '@nestjs/platform-express'; import { FilesInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { PrismaService } from '../prisma/prisma.service'; import { ParticipantRole } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { AdminGuard } from './auth/admin.guard'; import * as nodemailer from 'nodemailer'; import { JwtModule, JwtService } from '@nestjs/jwt'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtStrategy } from './auth/jwt.strategy'; import { Reflector } from '@nestjs/core'; import { SetMetadata, CanActivate, ExecutionContext } from '@nestjs/common'; import { CacheModule, CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { redisStore } from 'cache-manager-redis-yet'; import { HttpAdapterHost } from '@nestjs/core'; import { CompressCacheInterceptor } from './common/compress-cache.interceptor'; // Promisify các hàm nén để sử dụng async/await const gzip = promisify(zlib.gzip); const gunzip = promisify(zlib.gunzip); // Khai báo vị trí thư mục upload cụ thể const UPLOAD_ROOT = path.join(process.cwd(), 'uploads'); @WebSocketGateway({ cors: { origin: '*' } }) export class CommentGateway implements OnGatewayConnection { @WebSocketServer() server: Server; handleConnection(client: Socket) { console.log(`[WS] Client connected: ${client.id}`); } @SubscribeMessage('joinTour') handleJoinTour(client: Socket, tourId: string) { client.join(`tour_${tourId}`); console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`); } @SubscribeMessage('joinPhoto') handleJoinPhoto(client: Socket, photoId: string) { client.join(`photo_${photoId}`); console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`); } notifyNewComment(tourId: string, data: any) { // Gửi thông báo tới tất cả client trong phòng của Tour này this.server.to(`tour_${tourId}`).emit('commentAdded', data); } notifyNewPhotoComment(photoId: string, data: any) { // Gửi thông báo tới tất cả client trong phòng của Ảnh này this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data); } @SubscribeMessage('joinUser') handleJoinUser(client: Socket, userId: string) { client.join(`user_${userId}`); console.log(`[WS] Client ${client.id} joined user room: user_${userId}`); } notifyNewMessage(receiverId: string, data: any) { this.server.to(`user_${receiverId}`).emit('messageReceived', data); } notifyConnectionAccepted(requesterId: string, data: any) { this.server.to(`user_${requesterId}`).emit('connectionAccepted', data); } notifyJoinRequestAccepted(userId: string, data: any) { this.server.to(`user_${userId}`).emit('joinRequestAccepted', data); } } // Cấu hình TTL (mili giây) cho từng loại dữ liệu const CACHE_TTL = { DEFAULT: 600000, // 10 phút mặc định RESOURCE_TO_TOUR: 3600000, // 1 giờ cho ánh xạ tài nguyên -> tour USER_ROLE: 300000, // 5 phút cho quyền hạn người dùng }; async function bootstrap() { // Chuyển sang dùng NestExpressApplication để cấu hình static assets const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api/v1'); // Bật CORS để cho phép Frontend kết nối API không bị chặn app.enableCors(); // Tự động tạo thư mục upload nếu chưa tồn tại if (!fs.existsSync(UPLOAD_ROOT)) { fs.mkdirSync(UPLOAD_ROOT, { recursive: true }); } // Khai báo vị trí để ảnh upload có thể truy cập được từ bên ngoài qua URL app.useStaticAssets(UPLOAD_ROOT, { prefix: '/uploads/', }); const prisma = app.get(PrismaService); startAutoCleanup(prisma); await app.listen(3001); console.log(`🚀 Server is running on: http://localhost:3001`); } // Define ROLES_KEY and Roles decorator export const ROLES_KEY = 'roles'; export const Roles = (...roles: ParticipantRole[]) => SetMetadata(ROLES_KEY, roles); // Implement TourRoleGuard (assuming it's here or similar to this) // This guard checks if the user is a participant of the tour and has one of the required roles. @Injectable() export class TourRoleGuard implements CanActivate { constructor( private reflector: Reflector, private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache ) {} async canActivate(context: ExecutionContext): Promise { const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ context.getHandler(), context.getClass(), ]); // If no specific roles are required, default to OWNER and MANAGER for editing actions const defaultRoles = [ParticipantRole.OWNER, ParticipantRole.MANAGER]; const rolesToCheck = requiredRoles && requiredRoles.length > 0 ? requiredRoles : defaultRoles; const request = context.switchToHttp().getRequest(); const user = request.user; // User object from JwtAuthGuard let tourId = request.params.tourId; const resourceId = request.params.id || request.params.legId || request.params.locationId; // Nếu không có tourId trực tiếp, tìm tourId thông qua các tài nguyên liên quan 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 { // Thử xem resourceId có phải là tourId không const isTour = await this.prisma.tour.findUnique({ where: { id: resourceId }, select: { id: true } }); if (isTour) { tourId = resourceId; } else { // Thử xem resourceId có phải là legId không const leg = await this.prisma.leg.findUnique({ where: { id: resourceId }, select: { tourId: true } }); if (leg) { tourId = leg.tourId; } else { // Thử xem resourceId có phải là locationId không const loc = await this.prisma.location.findUnique({ where: { id: resourceId }, include: { leg: { select: { tourId: true } } } }); if (loc) { tourId = loc.leg.tourId; } else { // Thử xem resourceId có phải là photoId không 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; // Cho phép chủ sở hữu ảnh đi qua if (!photo.tourId) return true; // Cho phép đi qua nếu ảnh không thuộc tour nào (ví dụ ảnh ẩn danh public) tourId = photo.tourId; } } } } // Cache ánh xạ tài nguyên -> tour trong 1 giờ để giảm tải query ngược 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) { // Nếu đây là các route công khai hoặc không liên quan đến Tour, cho phép đi qua // nhưng ở đây chúng ta đang áp dụng guard cho các route cần phân quyền Tour if (!resourceId && !request.params.tourId) return true; return false; } // Cache vai trò người dùng trong tour (5 phút) 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 ForbiddenException('Bạn không có quyền thực hiện hành động này trong tour này.'); } // Ensure tourId is attached to the request object for controllers to use if (tourId) { (request as any).tourId = tourId; } return true; } } @Injectable() export class EmailService { private transporter: any; 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', // true cho cổng 465, false cho cổng 587/25 auth: { user: user, pass: pass, }, }); // Tự động kiểm tra kết nối khi khởi tạo để phát hiện lỗi cấu hình sớm this.transporter.verify((error: any) => { 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: string, otp: string) { 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: string, tourTitle: string, inviteLink: string) { 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:

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; } } } @Controller() class AppController { @Get() getHello(): string { return 'Travel Planning API is running!'; } } @Controller('auth') class AuthController { constructor( private prisma: PrismaService, private jwtService: JwtService, // Inject ConfigService để đọc biến môi trường một cách an toàn // NestJS sẽ đảm bảo ConfigModule được tải trước khi AuthController được khởi tạo // Do đó, các biến môi trường sẽ luôn có sẵn ở đây. // Điều này cũng áp dụng cho EmailService. private configService: ConfigService, private emailService: EmailService, @Inject(CACHE_MANAGER) private cacheManager: Cache ) {} @Post('convert-guest') async convertGuestToOfficial(@Body() body: any) { const { guestId, email, password, name } = body; if (!guestId || !email || !password) { throw new BadRequestException('Vui lòng cung cấp đủ guestId, email và mật khẩu.'); } // 1. Kiểm tra xem email đã tồn tại với một tài khoản chính thức khác chưa const existingOfficialUser = await this.prisma.user.findFirst({ where: { email: email, isAnonymous: false, }, }); if (existingOfficialUser) { throw new BadRequestException('Email này đã được một tài khoản khác sử dụng.'); } // 2. Tìm tài khoản khách const guestUser = await this.prisma.user.findUnique({ where: { id: guestId }, }); if (!guestUser || !guestUser.isAnonymous) { throw new 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.'); } // 3. Mã hóa mật khẩu và cập nhật người dùng 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, // Cập nhật tên mới nếu có, nếu không giữ lại tên ẩn danh cũ isAnonymous: false, // Đánh dấu đây là tài khoản chính thức }, }); // 4. Tạo và trả về token để người dùng đăng nhập ngay lập tức 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, }, }; } @UseGuards(JwtAuthGuard) @Post('promote-admin') async promoteAdmin(@Body() body: { secretKey: string }, @Req() req: any) { const { secretKey } = body; const adminSecret = this.configService.get('ADMIN_SECRET_KEY') || 'yotrip_secret_admin_key'; if (secretKey !== adminSecret) { throw new 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 }; } @Post('create-guest') 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, }, }; } @Get('status') async getStatus() { const userCount = await this.prisma.user.count(); console.log(`[Status Check] Users found: ${userCount}`); return { isInitialSetup: userCount === 0 }; } @Post('login') async login(@Body() body: any) { 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 UnauthorizedException('Email hoặc mật khẩu không chính xác'); } // Chặn người dùng đã bị khóa đăng nhập if (user.isBlocked) { throw new 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, }, }; } @Post('google') async googleLogin(@Body() body: { credential: string }) { const { credential } = body; if (!credential) { throw new 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 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 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 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 UnauthorizedException || error instanceof BadRequestException) { throw error; } throw new BadRequestException('Đã xảy ra lỗi khi đăng nhập bằng Google.'); } } @Post('signup/request') async signupRequest(@Body() body: any) { const { email, password, name, phone, address } = body; const existingUser = await this.prisma.user.findUnique({ where: { email } }); if (existingUser) throw new BadRequestException('Email đã được sử dụng'); const otp = Math.floor(100000 + Math.random() * 900000).toString(); // Lưu OTP và dữ liệu form đăng ký vào Cache trong vòng 5 phút (300000 ms) 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 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.'); } } @Post('signup/verify') async signupVerify(@Body() body: { email: string; otp: string; guestId?: string }) { const { email, otp, guestId } = body; const storedOtp = await this.cacheManager.get(`signup_otp:${email}`); if (!storedOtp || storedOtp !== otp) { throw new 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 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) { // Tìm tài khoản khách const guestUser = await this.prisma.user.findUnique({ where: { id: guestId } }); if (guestUser && guestUser.isAnonymous) { // Kiểm tra xem email này đã được sử dụng bởi một tài khoản chính thức khác chưa const existingUser = await this.prisma.user.findFirst({ where: { email, isAnonymous: false } }); if (existingUser) { throw new BadRequestException('Email này đã được đăng ký bởi tài khoản khác.'); } // Cập nhật thông tin của tài khoản khách 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 } }); } // Xóa dữ liệu cache sau khi đăng ký thành công await Promise.all([ this.cacheManager.del(`signup_otp:${email}`), this.cacheManager.del(`signup_data:${email}`) ]); return user; } } @Controller('tours') // Controller mới để xử lý các tour công khai class PublicTourController { constructor(private prisma: PrismaService) {} @UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache @Get(':id/public') async getPublicTourDetails(@Param('id', ParseUUIDPipe) id: string) { 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 NotFoundException(`Không tìm thấy Tour`); } return tour; } } @Controller('tours') class TourController { constructor( private prisma: PrismaService, private emailService: EmailService, @Inject(CACHE_MANAGER) private cacheManager: Cache, private commentGateway: CommentGateway ) {} @UseGuards(JwtAuthGuard) @Post() async createTour(@Body() body: any, @Req() req: any) { 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: any) => ({ 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 } } } } } }); await this.cacheManager.del(`/api/v1/tours/explore`); return tour; } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER) // Allow members to add locations @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/locations') async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { 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 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, } }); } // Xóa triệt để các loại cache của Tour (cả key UUID và key URL của Interceptor) await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); return loc; // Return the created location }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set start point @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/start-point') async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { const { latitude, longitude, name, plannedEnd } = body; console.log(`[USER ACTION] User ${req.user.id} set START point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`); // 1. Xóa tất cả các điểm bắt đầu cũ của Tour này (được đánh dấu bằng plannedStart = 0) // để đảm bảo tính duy nhất và sạch sẽ của dữ liệu. await this.prisma.location.deleteMany({ where: { leg: { tourId: tourId }, plannedStart: new Date(0) } }); // Tìm chặng đầu tiên của tour để ghim điểm xuất phát const firstLeg = await this.prisma.leg.findFirst({ where: { tourId }, orderBy: { sequence: 'asc' } }); if (!firstLeg) throw new NotFoundException('Không tìm thấy chặng đầu tiên cho Tour này'); // 2. Tạo mới điểm xuất phát tại Chặng 1 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; }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set end point @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/end-point') async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { const { latitude, longitude, name, plannedStart } = body; console.log(`[USER ACTION] User ${req.user.id} set END point for Tour ${tourId}: "${name}" at [${latitude}, ${longitude}]`); // Xóa điểm kết thúc cũ (được đánh dấu bằng plannedEnd = 0) để tránh trùng lặp ghim trên bản đồ await this.prisma.location.deleteMany({ where: { leg: { tourId: tourId }, plannedEnd: new Date(0) } }); // Tìm chặng cuối cùng của tour để ghim điểm kết thúc const lastLeg = await this.prisma.leg.findFirst({ where: { tourId }, orderBy: { sequence: 'desc' } }); if (!lastLeg) throw new 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), // Đánh dấu đây là điểm kết thúc đặc biệt } }).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; }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can initialize legs @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/legs/batch') async initializeLegs(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { count: number }) { const { count } = body; if (count <= 0 || count > 20) throw new BadRequestException('Số lượng chặng không hợp lệ (1-20)'); // 1. Lấy danh sách chặng hiện có const existingLegs = await this.prisma.leg.findMany({ where: { tourId }, orderBy: { sequence: 'asc' } }); // 2. Tạo thêm chặng nếu số lượng hiện tại chưa đủ 'count' 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 }); } // 3. Lấy chặng cuối cùng sau khi đã cập nhật const allLegs = await this.prisma.leg.findMany({ where: { tourId }, orderBy: { sequence: 'asc' } }); const lastLeg = allLegs[allLegs.length - 1]; // 4. Tự động di chuyển Điểm kết thúc sang Chặng cuối cùng (nếu đã khai báo điểm kết thúc) 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 } }); } // Invalidate cache for the tour after initializing legs await Promise.all([ this.cacheManager.del(tourId), this.cacheManager.del(`/api/v1/tours/${tourId}`), this.cacheManager.del(`/api/v1/tours/${tourId}/public`), ]); return allLegs; // Return all legs } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add legs @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/legs') async addLeg(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) { const tour = await this.prisma.tour.findUnique({ where: { id: tourId }, include: { legs: true } }); if (!tour) throw new 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; }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can update tour details @UseGuards(JwtAuthGuard, TourRoleGuard) @Patch(':id') async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) { const updateData: any = { 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; }); } @Roles(ParticipantRole.OWNER) // Only owner can delete tour @UseGuards(JwtAuthGuard, TourRoleGuard) @Delete(':id') async deleteTour(@Param('id', ParseUUIDPipe) id: string) { // 1. Lấy thông tin chi tiết Tour cùng các tài nguyên liên quan để dọn dẹp cache và file const tour = await this.prisma.tour.findUnique({ where: { id }, include: { participants: true, photos: true, legs: { include: { locations: true } } } }); if (!tour) throw new NotFoundException('Không tìm thấy tour'); // --- BẮT ĐẦU DỌN DẸP CACHE --- // a. Xóa cache vai trò của tất cả thành viên trong tour này for (const participant of tour.participants) { if (participant.userId) { await this.cacheManager.del(`user-role:${participant.userId}:${id}`); } } // b. Xóa cache mapping tài nguyên (Leg, Location, Photo) về Tour này await this.cacheManager.del(`res-to-tour:${id}`); // Bản thân tour 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}`); } // --- KẾT THÚC DỌN DẸP CACHE --- // Soft-delete the tour await this.prisma.tour.update({ where: { id }, data: { isDeleted: true, deletedAt: new Date() } }); // Soft-delete all related photos await this.prisma.photo.updateMany({ where: { tourId: id }, data: { isDeleted: true, deletedAt: new Date() } }); // Soft-delete all related notes await this.prisma.tourNote.updateMany({ where: { tourId: id }, data: { isDeleted: true, deletedAt: new Date() } }); // Xóa cache explore để Tour biến mất ngay lập tức trên bản đồ cộng đồng 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 }; } @UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache @UseGuards(JwtAuthGuard) @Get('explore') async getPublicTours(@Req() req: any) { // Trả về tất cả các tour trong hệ thống để hiển thị trên bản đồ cộng đồng 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 } } } } } } } }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can view tour details @UseGuards(JwtAuthGuard, TourRoleGuard) @Get(':id') async getTourDetails(@Param('id', ParseUUIDPipe) id: string) { 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 NotFoundException(`Không tìm thấy Tour với ID ${id}`); return tour; } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add members @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/members') async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string; displayName?: string; role?: string }, @Req() req: any) { const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const; const role = validRoles.includes(body.role as any) ? body.role as 'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY' : '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 BadRequestException('Vui lòng cung cấp userId hoặc displayName'); } // Xóa cache khi thay đổi quyền hạn hoặc thêm thành viên mới 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 } } }, }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) @UseGuards(JwtAuthGuard, TourRoleGuard) @Patch(':tourId/members/:userId') async updateMemberCounts( @Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string, @Body() body: { adultCount?: number; childCount?: number; role?: string }, @Req() req: any ) { const participant = await this.prisma.tourParticipant.findFirst({ where: { OR: [ { id: userId }, { tourId, userId } ] } }); if (!participant) { throw new NotFoundException('Không tìm thấy thành viên trong tour.'); } const isSelf = req.user.id === participant.userId; // Tìm quyền hạn của người gửi yêu cầu trong tour này 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 ForbiddenException('Bạn không có quyền chỉnh sửa thông tin thành viên này.'); } const data: any = {}; if (body.adultCount !== undefined) { if (body.adultCount < 1) throw new 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 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'] as const; if (validRoles.includes(body.role as any)) { data.role = body.role; } } // Xóa cache liên quan 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 } } }, }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can view join requests @UseGuards(JwtAuthGuard, TourRoleGuard) @Get(':tourId/join-requests') async getJoinRequests(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) { 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; } @UseGuards(JwtAuthGuard) @Post(':tourId/join-requests') async createJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string }, @Req() req: any) { const requestingUserId = body.userId || req.user.id; const existingParticipation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId: requestingUserId } }, }); if (existingParticipation) { throw new 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; } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can accept join requests @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/join-requests/:requestId/accept') async acceptJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) { 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 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 NotFoundException('Không tìm thấy yêu cầu tham gia.'); } if (joinRequest.status !== 'PENDING') { throw new 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.' }; } @Roles(ParticipantRole.OWNER) // Only owner can reject join requests @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/join-requests/:requestId/reject') async rejectJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) { 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 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 NotFoundException('Không tìm thấy yêu cầu tham gia.'); } if (joinRequest.status !== 'PENDING') { throw new 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.' }; } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can remove members @UseGuards(JwtAuthGuard, TourRoleGuard) @Delete(':tourId/members/:userId') async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) { const participant = await this.prisma.tourParticipant.findFirst({ where: { OR: [ { id: userId }, { tourId, userId } ] } }); if (!participant) { throw new 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' }; } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) // All members can upload photos @UseGuards(JwtAuthGuard, TourRoleGuard) // TourRoleGuard will now check for these roles @Post(':tourId/photos') @UseInterceptors(FilesInterceptor('images', 10)) // Chuyển sang memory storage để xử lý ảnh trước khi lưu async uploadPhotos(@Param('tourId', ParseUUIDPipe) tourId: string, @UploadedFiles() files: any[], @Req() req: any) { if (!files || files.length === 0) { throw new BadRequestException('Vui lòng chọn ít nhất một ảnh'); } const uploaderId = req.user.id; // Đường dẫn ảnh gốc cho từng thành viên const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals'); // Đường dẫn ảnh hiển thị chung của Tour const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours'); // Đảm bảo các thư mục tồn tại if (!fs.existsSync(memberOriginalDir)) fs.mkdirSync(memberOriginalDir, { recursive: true }); if (!fs.existsSync(tourDisplayPath)) fs.mkdirSync(tourDisplayPath, { recursive: true }); // Trích xuất tọa độ GPS dự phòng từ request body gửi từ frontend 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); // 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của thành viên await fs.promises.writeFile(originalFilePath, file.buffer); // 2. Trích xuất GPS từ EXIF let lat: number | undefined; let lng: number | undefined; try { const gps = await exifr.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); } // 3. Nếu EXIF không có GPS, dùng GPS dự phòng của thiết bị gửi từ Frontend if (lat === undefined || lng === undefined) { if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) { lat = bodyLat; lng = bodyLng; } } // 4. Nếu vẫn không có vị trí nào, ghim tại vị trí mặc định [10.7769, 106.7009] if (lat === undefined || lng === undefined) { lat = 10.7769; lng = 106.7009; } // 5. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px) 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 heicConvert({ 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); } } // Sử dụng Sharp để resize và tối ưu dung lượng ảnh await sharp(processBuffer) .rotate() .resize(2560, 2560, { fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh }) .jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng .toFile(displayFilePath); // 6. Lưu thông tin vào Database (Lưu cả 2 đường dẫn và metadata GPS) return this.prisma.photo.create({ data: { tourId: tourId, uploaderId: uploaderId, imageUrl: `/uploads/tours/${displayFilename}`, // URL ảnh 2K dùng để render (luôn là .jpg) originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`, // URL ảnh gốc để tải xuống privacy: 'TOUR_ONLY', metadata: { lat: lat, lng: lng } } }); })); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/invitations') async createInvitation( @Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { email: string; role?: ParticipantRole }, @Req() req: any ) { const { email, role = ParticipantRole.MEMBER } = body; if (!email) { throw new 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 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 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); // 7 days 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 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!' }; } @UseGuards(JwtAuthGuard) @Post('join-by-token') async joinByToken(@Body() body: { token: string }, @Req() req: any) { const { token } = body; if (!token) { throw new BadRequestException('Vui lòng cung cấp token lời mời.'); } const invitation = await this.prisma.tourInvitation.findUnique({ where: { token }, include: { tour: { select: { title: true } } } }); if (!invitation) { throw new NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.'); } if (invitation.expiredAt < new Date()) { try { await this.prisma.tourInvitation.delete({ where: { id: invitation.id } }); } catch (e) {} throw new BadRequestException('Lời mời đã hết hạn.'); } const userId = req.user.id; const existingParticipation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId: invitation.tourId, userId } } }); if (existingParticipation) { try { await this.prisma.tourInvitation.delete({ where: { id: invitation.id } }); } catch (e) {} // Xóa cache để đảm bảo dữ liệu thành viên luôn mới nhất 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 }; } // Xóa cache TRƯỚC để đảm bảo không có race condition 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 } }) ]); return { success: true, tourId: invitation.tourId, message: `Bạn đã gia nhập hành trình "${invitation.tour.title}"!`, shouldRefresh: true }; } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/members/merge') async mergeMember( @Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { manualParticipantId: string; systemUserId: string }, @Req() req: any ) { const { manualParticipantId, systemUserId } = body; if (!manualParticipantId || !systemUserId) { throw new 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 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 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 !== 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.' }; } } @Controller('locations') @UseGuards(JwtAuthGuard) class LocationController { constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {} @Patch(':id') @UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId async updateLocation(@Param('id', ParseUUIDPipe) id: string, @Body() body: any, @Req() req: any) { 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; } @Delete(':id') @UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId async deleteLocation(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { try { await this.prisma.location.delete({ where: { id } }); } catch (e) { // Nếu bản ghi đã bị xóa trước đó, không ném lỗi 500 để đảm bảo tính an toàn (idempotency) } // Xóa mapping cache để Guard không bị đánh lừa ở lần truy cập sau 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 }; } } @Controller('legs') @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Leg operations @UseGuards(JwtAuthGuard, TourRoleGuard) class LegController { constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {} @Patch(':id') @UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId async updateLeg(@Param('id', ParseUUIDPipe) id: string, @Body() body: any, @Req() req: any) { 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; }); } @Delete(':id') @UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId async deleteLeg(@Param('id', ParseUUIDPipe) id: string) { const leg = await this.prisma.leg.findUnique({ where: { id }, include: { _count: { select: { locations: true } } } }); if (leg?._count.locations && leg._count.locations > 0) { throw new 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) { // Idempotency } return { success: true }; } } /** * Helper tính khoảng cách giữa 2 tọa độ (Haversine formula) */ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number) { const p = 0.017453292519943295; // Math.PI / 180 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)); // 2 * R; R = 6371 km } @Controller('routing') @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Routing operations @UseGuards(JwtAuthGuard, TourRoleGuard) class RoutingController { constructor(private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache) {} @Post('optimize/:legId') @UseGuards(TourRoleGuard) // Apply TourRoleGuard to get tourId async optimize(@Param('legId', ParseUUIDPipe) legId: string, @Req() req: any) { const currentLeg = await this.prisma.leg.findUnique({ where: { id: legId }, }); if (!currentLeg) throw new 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 }; // KHAI BÁO startAnchor ở đầu hàm để tránh lỗi scope TS2304 let startAnchor: any = null; // Tìm địa điểm cuối cùng của chặng trước đó 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 }; // Thuật toán Greedy TSP đơn giản để tối ưu hóa lộ trình const optimized = []; const unvisited = [...locations]; // Bắt đầu với địa điểm có thời gian dự kiến sớm nhất hiện tại let current: any; 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); } // Tính toán tổng quãng đường di chuyển của chặng (km) let totalDistance = 0; // Cộng thêm quãng đường từ chặng trước nối sang chặng này 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 ); } // Cập nhật lại thời gian plannedStart trong DB để phản ánh thứ tự mới (mỗi điểm cách nhau 1 giờ giả định) 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)) }; } } @Controller('photos') @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) // All members can delete their own photos @UseGuards(JwtAuthGuard, TourRoleGuard) class PhotoController { constructor(private prisma: PrismaService) {} @Post('upload-anonymous') @UseGuards(JwtAuthGuard) // Vẫn dùng Guard để lấy user từ token @UseInterceptors(FilesInterceptor('images', 1)) // Chỉ cho phép 1 ảnh mỗi lần async uploadAnonymousPhoto(@UploadedFiles() files: any[], @Req() req: any) { if (!files || files.length === 0) { throw new BadRequestException('Vui lòng chọn một ảnh.'); } const uploaderId = req.user.id; const isAnonymous = req.user.isAnonymous; // Chỉ người dùng ẩn danh mới được dùng endpoint này if (!isAnonymous) { throw new 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 displayFilePath = path.join(UPLOAD_ROOT, 'tours', displayFilename); const originalFilePath = path.join(memberOriginalDir, originalFilename); // Đảm bảo thư mục lưu trữ ảnh gốc tồn tại if (!fs.existsSync(memberOriginalDir)) { fs.mkdirSync(memberOriginalDir, { recursive: true }); } // 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của khách await fs.promises.writeFile(originalFilePath, file.buffer); // 2. Trích xuất GPS từ EXIF bằng exifr let lat: number | undefined; let lng: number | undefined; try { const gps = await exifr.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); } // 3. Nếu EXIF không có tọa độ, dùng tọa độ dự phòng gửi từ frontend 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}`); } } // 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM) 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}`); } // 5. Chuyển đổi HEIC nếu cần để lưu hiển thị 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 heicConvert({ 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); } } // 6. Xử lý ảnh để hiển thị (kích thước tối đa 2K: 2560px) await sharp(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 } }, }); } @Delete(':id') async deletePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { const photo = await this.prisma.photo.findUnique({ where: { id }, }); if (!photo) { throw new NotFoundException('Không tìm thấy ảnh.'); } // Chỉ người tải lên hoặc Admin mới có quyền xóa ảnh if (photo.uploaderId !== req.user.id && !req.user.isAdmin) { throw new 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.' }; } @Patch(':id') async updatePhoto( @Param('id', ParseUUIDPipe) id: string, @Body() body: { title?: string; description?: string; latitude?: number; longitude?: number }, @Req() req: any ) { const photo = await this.prisma.photo.findUnique({ where: { id } }); if (!photo) { throw new NotFoundException('Không tìm thấy ảnh.'); } // Chỉ người tải lên hoặc Admin mới có quyền sửa thông tin ảnh if (photo.uploaderId !== req.user.id && !req.user.isAdmin) { throw new 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 as any) || {}; 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 } }); } @Post(':id/toggle-like') @UseGuards(JwtAuthGuard) async toggleLikePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { const userId = req.user.id; const photo = await this.prisma.photo.findUnique({ where: { id } }); if (!photo) { throw new NotFoundException('Không tìm thấy ảnh.'); } const currentMetadata = (photo.metadata as any) || {}; const likedUserIds = Array.isArray(currentMetadata.likedUserIds) ? currentMetadata.likedUserIds : []; const index = likedUserIds.indexOf(userId); let updatedLikedUserIds = [...likedUserIds]; if (index > -1) { // Unlike updatedLikedUserIds.splice(index, 1); } else { // Like 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 } } } }); } } @UseGuards(JwtAuthGuard) @Controller('users') @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for User management (Admin/Manager) class UserController { constructor(private prisma: PrismaService) {} @Get() async getAllUsers(@Req() req: any, @Query('q') q?: string) { const currentUserId = req.user?.id; const users = await this.prisma.user.findMany({ where: { isAnonymous: false, ...(q ? { OR: [ { name: { contains: q, mode: 'insensitive' as any } }, { email: { contains: q, mode: 'insensitive' as any } }, ], } : {}), }, select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true } }); return users.filter((u: any) => u.id !== currentUserId); } // getMyPhotos does not need TourRoleGuard as it's for the user's own photos @UseGuards(JwtAuthGuard) @Get('me/photos') async getMyPhotos(@Req() req: any) { return this.prisma.photo.findMany({ where: { uploaderId: req.user.id, isDeleted: false }, include: { tour: { select: { title: true } } }, orderBy: { capturedAt: 'desc' } }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for updating user (Admin/Manager) @Patch(':id') async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any, @Req() req: any) { const requestingUser = req.user; // Fetch target user from DB const targetUser = await this.prisma.user.findUnique({ where: { id } }); if (!targetUser) { throw new NotFoundException('Không tìm thấy người dùng'); } // 1. If target user is an Admin, only an Admin can update them. // (A manager/normal user cannot reset/change password of an Admin) if (targetUser.isAdmin && !requestingUser.isAdmin) { throw new ForbiddenException('Không có quyền thay đổi thông tin hoặc reset password của Quản trị viên'); } // 2. A non-admin can only update their own profile. if (!requestingUser.isAdmin && requestingUser.id !== id) { throw new 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; } // Safety: prevent non-admins from promoting anyone to admin 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 } }); } @Roles(ParticipantRole.OWNER) // Only owner can delete user @Delete(':id') @UseGuards(AdminGuard) async deleteUser(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new 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 BadRequestException('Không thể xóa Quản trị viên cuối cùng'); } // 1. Xác định thư mục chứa ảnh gốc của thành viên const memberDir = path.join(UPLOAD_ROOT, 'members', id); // 2. Tìm tất cả ảnh của user này để dọn dẹp nốt các bản 2K còn lại trong thư mục tours 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); } } // 2. Xóa các ràng buộc và dữ liệu trong DB await this.prisma.photo.deleteMany({ where: { uploaderId: id } }); await this.prisma.tourParticipant.deleteMany({ where: { userId: id } }); await this.prisma.user.delete({ where: { id } }); // 3. Xóa vật lý toàn bộ thư mục ảnh gốc if (fs.existsSync(memberDir)) { fs.rmSync(memberDir, { recursive: true, force: true }); } return { message: 'Đã xóa người dùng' }; } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for blocking user (Admin/Manager) @Post('block/:id') @UseGuards(AdminGuard) async toggleBlock(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new NotFoundException('Người dùng không tồn tại'); // Safety check: Cannot block an Admin if (user.isAdmin) { throw new 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; } } @Controller('locations') class CommentController { constructor( private prisma: PrismaService, private commentGateway: CommentGateway ) {} @Get(':locationId/comments') // Cho phép khách xem bình luận mà không cần đăng nhập async getComments(@Param('locationId', ParseUUIDPipe) locationId: string) { return this.prisma.comment.findMany({ where: { locationId }, include: { user: { select: { name: true } } }, orderBy: { createdAt: 'asc' } }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':locationId/comments') async addComment( @Param('locationId', ParseUUIDPipe) locationId: string, @Body() body: { content: string }, @Req() req: any ) { 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 } } } }); // Tìm tourId để gửi thông báo vào đúng phòng 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 // Gửi kèm locationId để UI biết điểm nào cần tăng số lượng }); } return comment; } } @Controller('admin/otp') @UseGuards(JwtAuthGuard, AdminGuard) class AdminOtpController { constructor( private prisma: PrismaService, private emailService: EmailService, @Inject(CACHE_MANAGER) private cacheManager: Cache ) {} @Post('send') async sendOtpToUser(@Body() body: { email: string }, @Req() req: any) { const { email } = body; // 1. Xác định định danh: IP và Email const clientIp = req.ip || req.headers['x-forwarded-for'] || req.connection.remoteAddress; const emailLimitKey = `rl:otp:email:${email}`; const ipLimitKey = `rl:otp:ip:${clientIp}`; // 2. Kiểm tra xem IP hoặc Email này có đang bị giới hạn không (ví dụ: 1 phút 1 lần) const [isEmailLimited, isIpLimited] = await Promise.all([ this.cacheManager.get(emailLimitKey), this.cacheManager.get(ipLimitKey) ]); if (isEmailLimited || isIpLimited) { throw new 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ã.', HttpStatus.TOO_MANY_REQUESTS ); } const user = await this.prisma.user.findUnique({ where: { email } }); if (!user) { throw new 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); // 3. Thiết lập khóa chặn sau khi gửi thành công (hết hạn sau 60 giây) 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 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'); } } @Post('verify') async verifyOtp(@Body() body: { email: string; otp: string }) { const { email, otp } = body; const otpCacheKey = `otp:${email}`; const failCountKey = `otp_fails:${email}`; const MAX_FAILED_ATTEMPTS = 5; // 1. Kiểm tra tài khoản có đang bị khóa không const user = await this.prisma.user.findUnique({ where: { email } }); if (!user) throw new NotFoundException('Người dùng không tồn tại'); if (user.isBlocked) { throw new ForbiddenException('Tài khoản này hiện đang bị khóa. Vui lòng liên hệ quản trị viên.'); } // 2. Lấy OTP từ Cache const storedOtp = await this.cacheManager.get(otpCacheKey); if (!storedOtp) { throw new 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.'); } // 3. So sánh mã OTP if (storedOtp === otp) { // Thành công: Xóa OTP và bộ đếm lỗi 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 { // Thất bại: Tăng bộ đếm lỗi let fails: number = (await this.cacheManager.get(failCountKey)) || 0; fails++; if (fails >= MAX_FAILED_ATTEMPTS) { // Khóa tài khoản trong DB await this.prisma.user.update({ where: { email }, data: { isBlocked: true } }); await this.cacheManager.del(failCountKey); await this.cacheManager.del(otpCacheKey); throw new ForbiddenException(`Bạn đã nhập sai quá ${MAX_FAILED_ATTEMPTS} lần. Tài khoản đã bị khóa để bảo mật.`); } else { // Cập nhật số lần sai vào cache (TTL 5 phút bằng với OTP) await this.cacheManager.set(failCountKey, fails, 300000); throw new 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 }); } } } } @Controller('admin/trash-photos') @UseGuards(JwtAuthGuard, AdminGuard) class AdminController { constructor(private prisma: PrismaService) {} @Get() async getTrashPhotos() { // 1. Lấy tất cả ảnh trong DB 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) ); // 2. Tìm tất cả tệp tin vật lý trên đĩa const allDiskFiles: string[] = []; const getFilesRecursively = (dir: string) => { 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: any[] = []; // 3. Kiểm tra các tệp tin trên đĩa xem có trong DB không 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' }); } } // 4. Kiểm tra các bản ghi DB mồ côi (không có tourId và privacy không phải PUBLIC) 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; } @Delete('clean') 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 }; } @Delete('delete-single') async deleteSingleTrash(@Body() body: { type: 'file_only' | 'db_orphaned'; filePath?: string; id?: string }) { const { type, filePath, id } = body; if (type === 'file_only') { if (!filePath) throw new BadRequestException('Đường dẫn tệp tin không hợp lệ'); const resolvedPath = path.resolve(filePath); if (!resolvedPath.startsWith(UPLOAD_ROOT)) { throw new 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 NotFoundException('Tệp tin không tồn tại'); } } else if (type === 'db_orphaned') { if (!id) throw new BadRequestException('ID bản ghi không hợp lệ'); const photo = await this.prisma.photo.findUnique({ where: { id } }); if (!photo) throw new 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 BadRequestException('Yêu cầu không hợp lệ'); } } @Controller('public-photos') class PublicPhotoController { constructor( private prisma: PrismaService, private commentGateway: CommentGateway ) {} @Get() 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' } }); } @Get(':photoId/comments') async getPhotoComments(@Param('photoId', ParseUUIDPipe) photoId: string) { return this.prisma.comment.findMany({ where: { photoId }, include: { user: { select: { id: true, name: true } } }, orderBy: { createdAt: 'asc' } }); } @UseGuards(JwtAuthGuard) @Post(':photoId/comments') async addPhotoComment( @Param('photoId', ParseUUIDPipe) photoId: string, @Body() body: { content: string }, @Req() req: any ) { const { content } = body; if (!content || content.trim() === '') { throw new 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 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 } } } }); // Phát tín hiệu WebSocket cho các client đang xem ảnh này this.commentGateway.notifyNewPhotoComment(photoId, comment); return comment; } @UseGuards(JwtAuthGuard) @Delete('comments/:id') async deletePhotoComment(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { const comment = await this.prisma.comment.findUnique({ where: { id }, include: { photo: true } }); if (!comment) { throw new 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 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.' }; } @Get(':photoId/share') async sharePhoto( @Param('photoId', ParseUUIDPipe) photoId: string, @Req() req: any, @Res() res: any ) { const photo = await this.prisma.photo.findUnique({ where: { id: photoId } }); if (!photo || photo.isDeleted) { throw new NotFoundException('Không tìm thấy ảnh.'); } let host = req.headers['x-forwarded-host'] || req.headers.host || ''; // Nếu host là localhost/127.0.0.1 nhưng request có header proxy (nghĩa là đang chạy qua Nginx proxy ở production) 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}`; // Lấy thông tin metadata 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 as any; if (meta.title && meta.title.trim()) { title = meta.title; } if (meta.description && meta.description.trim()) { description = meta.description; } } // Xây dựng đường dẫn ảnh tuyệt đối để Facebook hiển thị thumbnail 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}`; // Lấy kích thước thực tế của ảnh bằng sharp để Facebook hiển thị chuẩn xác không cần chờ xử lý bất đồng bộ 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 sharp(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); } } @UseGuards(JwtAuthGuard) @Controller('connections') class ConnectionController { constructor( private prisma: PrismaService, private commentGateway: CommentGateway ) {} @Get() async getConnections(@Req() req: any) { const currentUserId = req.user.id; // Get connections where status is ACCEPTED 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 } } } }); // Format connections to return the target user and relation details 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, // FRIEND / FAMILY status: conn.status, createdAt: conn.createdAt }; }); // Get pending requests received by current user const receivedRequests = await this.prisma.userConnection.findMany({ where: { receiverId: currentUserId, status: 'PENDING' }, include: { requester: { select: { id: true, name: true, email: true, avatar: true } } } }); // Get pending requests sent by current user 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 })) }; } @Post() async sendRequest(@Req() req: any, @Body() body: { receiverId: string }) { const requesterId = req.user.id; const { receiverId } = body; if (requesterId === receiverId) { throw new 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 NotFoundException('Không tìm thấy người nhận.'); } // Check if relation already exists const existing = await this.prisma.userConnection.findFirst({ where: { OR: [ { requesterId, receiverId }, { requesterId: receiverId, receiverId } ] } }); if (existing) { if (existing.status === 'ACCEPTED') { throw new BadRequestException('Hai bạn đã kết nối với nhau.'); } if (existing.status === 'PENDING') { throw new BadRequestException('Yêu cầu kết nối đang chờ xử lý.'); } // If rejected, allow sending request again by deleting/updating the old one await this.prisma.userConnection.delete({ where: { id: existing.id } }); } const newConn = await this.prisma.userConnection.create({ data: { requesterId, receiverId, status: 'PENDING', type: 'FRIEND' // default } }); return { success: true, connection: newConn }; } @Patch(':id') async updateRequest( @Param('id', ParseUUIDPipe) id: string, @Body() body: { status?: 'ACCEPTED' | 'REJECTED'; type?: 'FRIEND' | 'FAMILY' }, @Req() req: any ) { const currentUserId = req.user.id; const conn = await this.prisma.userConnection.findUnique({ where: { id } }); if (!conn) { throw new NotFoundException('Không tìm thấy bản ghi kết nối.'); } // Accept/Reject request if (body.status) { if (conn.receiverId !== currentUserId) { throw new 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 }; } // Change relationship type (FRIEND <-> FAMILY) if (body.type) { if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) { throw new ForbiddenException('Bạn không có quyền thực hiện hành động này.'); } if (conn.status !== 'ACCEPTED') { throw new 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 BadRequestException('Yêu cầu không hợp lệ.'); } @Delete(':id') async deleteConnection(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) { const currentUserId = req.user.id; const conn = await this.prisma.userConnection.findUnique({ where: { id } }); if (!conn) { throw new NotFoundException('Không tìm thấy bản ghi kết nối.'); } if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) { throw new 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.' }; } } @UseGuards(JwtAuthGuard) @Controller('messages') class DirectMessageController { constructor( private prisma: PrismaService, private commentGateway: CommentGateway ) {} @Get(':userId') async getMessages(@Param('userId', ParseUUIDPipe) userId: string, @Req() req: any) { const currentUserId = req.user.id; // Check connection first to ensure they are connected const conn = await this.prisma.userConnection.findFirst({ where: { OR: [ { requesterId: currentUserId, receiverId: userId }, { requesterId: userId, receiverId: currentUserId } ], status: 'ACCEPTED' } }); if (!conn) { throw new 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; } @Post() async sendMessage( @Req() req: any, @Body() body: { receiverId: string; content?: string; attachmentUrl?: string; latitude?: number; longitude?: number } ) { const senderId = req.user.id; const { receiverId, content, attachmentUrl, latitude, longitude } = body; if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) { throw new BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.'); } // Check connection first const conn = await this.prisma.userConnection.findFirst({ where: { OR: [ { requesterId: senderId, receiverId: receiverId }, { requesterId: receiverId, receiverId: senderId } ], status: 'ACCEPTED' } }); if (!conn) { throw new 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 } } } }); // Realtime broadcast via Socket this.commentGateway.notifyNewMessage(receiverId, message); return message; } } @UseGuards(JwtAuthGuard) @Controller('upload') class UploadController { @Post() @UseInterceptors(FilesInterceptor('image', 1)) async uploadAttachment(@UploadedFiles() files: any[]) { if (!files || files.length === 0) { throw new 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}` }; } } @UseGuards(JwtAuthGuard) @Controller('tours/:tourId/messages') class TourMessageController { constructor( private prisma: PrismaService, private commentGateway: CommentGateway ) {} @Get() async getTourMessages(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) { const userId = req.user.id; // Check if user is a participant of this tour const participant = await this.prisma.tourParticipant.findFirst({ where: { tourId, userId } }); if (!participant) { throw new 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; } @Post() async sendTourMessage( @Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any, @Body() body: { content?: string; attachmentUrl?: string; latitude?: number; longitude?: number; taggedUserIds?: string[] } ) { 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 BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.'); } // Check if user is a participant of this tour const participant = await this.prisma.tourParticipant.findFirst({ where: { tourId, userId: senderId } }); if (!participant) { throw new 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 } } } }); // Broadcast to the WebSocket room of this tour this.commentGateway.server.to(`tour_${tourId}`).emit('tourMessageReceived', { tourId, message: tourMessage }); const hasTags = Array.isArray(taggedUserIds) && taggedUserIds.length > 0; // Notify other participants (via their user rooms) // If taggedUserIds is provided, we ONLY notify those tagged users. 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; } } async function filterText(prisma: PrismaService, text: string): Promise { 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; } } @Controller('moderation') class ModerationController { constructor(private prisma: PrismaService) {} @Get('settings') async getSettings() { let settings = await this.prisma.moderationSetting.findFirst(); if (!settings) { settings = await this.prisma.moderationSetting.create({ data: { blockNsfw: false, blurFaces: false } }); } return settings; } } @Controller('admin/moderation') @UseGuards(JwtAuthGuard, AdminGuard) class AdminModerationController { constructor(private prisma: PrismaService) {} @Get() async getSettings() { let settings = await this.prisma.moderationSetting.findFirst(); if (!settings) { settings = await this.prisma.moderationSetting.create({ data: { blockNsfw: false, blurFaces: false } }); } return settings; } @Post() async updateSettings(@Body() body: { blockNsfw?: boolean, blurFaces?: boolean }) { 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 } }); } @Get('word-filters') async getWordFilters() { return this.prisma.wordFilter.findMany({ orderBy: { createdAt: 'desc' } }); } @Post('word-filters') async addWordFilter(@Body() body: { word: string, replacement: string }) { const { word, replacement } = body; if (!word || replacement === undefined) { throw new 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() } }); } @Delete('word-filters/:id') async deleteWordFilter(@Param('id', ParseUUIDPipe) id: string) { await this.prisma.wordFilter.delete({ where: { id } }); return { success: true }; } } @Controller('reports') class ReportsController { constructor(private prisma: PrismaService) {} @Post() async createReport(@Body() body: any) { const { type, name, phone, email, address, latitude, longitude, reason } = body; if (!type || !name || !reason) { throw new 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, } }); } @Get('blacklist') async getBlacklist() { return this.prisma.businessReport.findMany({ where: { isBlacklisted: true }, orderBy: { createdAt: 'desc' } }); } } @Controller('admin/reports') @UseGuards(JwtAuthGuard, AdminGuard) class AdminReportsController { constructor(private prisma: PrismaService) {} @Get() async getAllReports() { return this.prisma.businessReport.findMany({ orderBy: { createdAt: 'desc' } }); } @Patch(':id/blacklist') async toggleBlacklist(@Param('id', ParseUUIDPipe) id: string, @Body() body: { isBlacklisted: boolean }) { const report = await this.prisma.businessReport.findUnique({ where: { id } }); if (!report) { throw new NotFoundException('Không tìm thấy báo cáo.'); } return this.prisma.businessReport.update({ where: { id }, data: { isBlacklisted: body.isBlacklisted } }); } @Delete(':id') async deleteReport(@Param('id', ParseUUIDPipe) id: string) { const report = await this.prisma.businessReport.findUnique({ where: { id } }); if (!report) { throw new NotFoundException('Không tìm thấy báo cáo.'); } await this.prisma.businessReport.delete({ where: { id } }); return { success: true }; } } @Controller('users/trusted') class TrustedUsersController { constructor(private prisma: PrismaService) {} @Get() 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); } } @Controller('tours/:tourId/ratings') @UseGuards(JwtAuthGuard) class TourRatingController { constructor(private prisma: PrismaService) {} @Get() async getTourRatings(@Param('tourId', ParseUUIDPipe) tourId: string) { 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' } }); } @Post() async createTourRating( @Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { targetUserId: string; honesty: number; transparency: number; enthusiasm: number; cheerfulness: number; seriousness: number; planning: number; survival: number; comment?: string; }, @Req() req: any ) { 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 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 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 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 } }); } } @Controller('tours') class PublicTourShareController { constructor(private prisma: PrismaService) {} @Get('share/:token') async getSharedJourney(@Param('token') token: string) { 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 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 }; } } @Controller('tours/:tourId/share') @UseGuards(JwtAuthGuard) class TourShareController { constructor(private prisma: PrismaService) {} @Get() async getShareStatus(@Param('tourId', ParseUUIDPipe) tourId: string) { let share = await this.prisma.tourShare.findUnique({ where: { tourId } }); if (!share) { share = await this.prisma.tourShare.create({ data: { tourId, isEnabled: false } }); } return share; } @Post() async toggleShare( @Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { isEnabled: boolean } ) { 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 } }); } } // ================= NEW CONTROLLERS & SERVICES ================= @Controller('tours/:tourId/notes') @UseGuards(JwtAuthGuard, TourRoleGuard) class TourNoteController { constructor(private prisma: PrismaService) {} @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) @Get() async getNotes(@Param('tourId') tourId: string) { return this.prisma.tourNote.findMany({ where: { tourId, isDeleted: false }, orderBy: { createdAt: 'desc' } }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) @Post() async createNote( @Param('tourId') tourId: string, @Body() body: { title: string; content: string }, @Req() req: any ) { 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, } }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) @Put(':noteId') async updateNote( @Param('tourId') tourId: string, @Param('noteId') noteId: string, @Body() body: { title?: string; content?: string }, @Req() req: any ) { const { title, content } = body; const note = await this.prisma.tourNote.findUnique({ where: { id: noteId } }); if (!note || note.tourId !== tourId || note.isDeleted) { throw new NotFoundException('Không tìm thấy ghi chú'); } if (note.userId !== req.user.id && !req.user.isAdmin) { throw new ForbiddenException('Bạn không có quyền sửa ghi chú này'); } const updateData: any = {}; 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 }); } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) @Delete(':noteId') async deleteNote( @Param('tourId') tourId: string, @Param('noteId') noteId: string, @Req() req: any ) { const note = await this.prisma.tourNote.findUnique({ where: { id: noteId } }); if (!note || note.tourId !== tourId || note.isDeleted) { throw new NotFoundException('Không tìm thấy ghi chú'); } if (note.userId !== req.user.id && !req.user.isAdmin) { throw new 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 }; } } @Controller('admin/notes') @UseGuards(JwtAuthGuard, AdminGuard) class AdminNoteController { constructor(private prisma: PrismaService) {} @Get() async getAllActiveNotes() { return this.prisma.tourNote.findMany({ where: { isDeleted: false }, include: { tour: { select: { title: true } }, user: { select: { name: true, email: true } } }, orderBy: { createdAt: 'desc' } }); } @Delete(':id') async softDeleteNote(@Param('id', ParseUUIDPipe) id: string) { await this.prisma.tourNote.update({ where: { id }, data: { isDeleted: true, deletedAt: new Date() } }); return { success: true }; } } @Controller('admin/tours') @UseGuards(JwtAuthGuard, AdminGuard) class AdminTourController { constructor(private prisma: PrismaService) {} @Get() async getAllActiveTours() { return this.prisma.tour.findMany({ where: { isDeleted: false }, include: { creator: { select: { name: true, email: true } }, _count: { select: { participants: true } } }, orderBy: { createdAt: 'desc' } }); } @Delete(':id') async softDeleteTour(@Param('id', ParseUUIDPipe) id: string) { 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 }; } } @Controller('recommendations') class RecommendedLocationController { constructor(private prisma: PrismaService) {} @Get() async getApprovedRecommendations() { return this.prisma.recommendedLocation.findMany({ where: { isApproved: true }, orderBy: { createdAt: 'desc' } }); } @UseGuards(JwtAuthGuard) @Post() async proposeRecommendation(@Body() body: any) { const { type, name, phone, email, address, latitude, longitude, description, stars } = body; if (!name || !type || !description) { throw new 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 } }); } } @Controller('admin/recommendations') @UseGuards(JwtAuthGuard, AdminGuard) class AdminRecommendedLocationController { constructor(private prisma: PrismaService) {} @Get() async getAllRecommendations() { return this.prisma.recommendedLocation.findMany({ orderBy: { createdAt: 'desc' } }); } @Patch(':id/approve') async approveRecommendation(@Param('id', ParseUUIDPipe) id: string, @Body() body: { isApproved: boolean }) { return this.prisma.recommendedLocation.update({ where: { id }, data: { isApproved: body.isApproved } }); } @Delete(':id') async deleteRecommendation(@Param('id', ParseUUIDPipe) id: string) { await this.prisma.recommendedLocation.delete({ where: { id } }); return { success: true }; } } @Controller('admin/trash') @UseGuards(JwtAuthGuard, AdminGuard) class AdminTrashController { constructor(private prisma: PrismaService) {} @Get() 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 }; } @Patch('retention-days') async updateRetentionDays(@Body() body: { days: number }) { const { days } = body; if (days === undefined || days < 1) { throw new 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 } }); } } @Post('restore') async restoreItems(@Body() body: { type: 'tour' | 'photo' | 'note'; ids: string[] }) { const { type, ids } = body; if (!type || !ids || !Array.isArray(ids)) { throw new 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 }; } @Post('delete-permanent') async deletePermanentItems(@Body() body: { type: 'tour' | 'photo' | 'note'; ids: string[] }) { const { type, ids } = body; if (!type || !ids || !Array.isArray(ids)) { throw new BadRequestException('Tham số không hợp lệ.'); } if (type === 'tour') { for (const tourId of ids) { const photos = await this.prisma.photo.findMany({ where: { tourId } }); for (const p of photos) { if (p.imageUrl) { const displayFilePath = path.join(process.cwd(), p.imageUrl.replace(/^\//, '')); if (fs.existsSync(displayFilePath)) { try { fs.unlinkSync(displayFilePath); } catch (e) {} } } if (p.originalUrl) { const originalFilePath = path.join(process.cwd(), p.originalUrl.replace(/^\//, '')); if (fs.existsSync(originalFilePath)) { try { fs.unlinkSync(originalFilePath); } catch (e) {} } } } await this.prisma.tour.delete({ where: { id: tourId } }); } } else if (type === 'photo') { for (const photoId of ids) { const photo = await this.prisma.photo.findUnique({ where: { id: photoId } }); 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) {} } } await this.prisma.photo.delete({ where: { id: photoId } }); } } } else if (type === 'note') { await this.prisma.tourNote.deleteMany({ where: { id: { in: ids } } }); } return { success: true }; } } // Background auto cleanup task function startAutoCleanup(prisma: PrismaService) { 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()}`); // 1. Ghi chú rác quá hạn 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.`); } // 2. Ảnh rác 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.`); } // 3. Tour rác 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); // 24 giờ } @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Giúp ConfigModule có sẵn ở mọi nơi trong ứng dụng // Chỉ định đường dẫn tới file .env ở thư mục gốc của dự án envFilePath: path.resolve(__dirname, '..', '..', '.env'), // Bỏ qua lỗi nếu không tìm thấy file .env (hữu ích cho môi trường production dùng biến hệ thống) ignoreEnvFile: process.env.NODE_ENV === 'production', }), CacheModule.registerAsync({ isGlobal: true, useFactory: async () => ({ store: await redisStore({ url: process.env.REDIS_URL || 'redis://localhost:6379', ttl: CACHE_TTL.DEFAULT, // Cấu hình TTL mặc định cho toàn bộ store }), }), }), JwtModule.register({ secret: process.env.JWT_SECRET || 'super-secret', signOptions: { expiresIn: '1d' }, }) as any, ], 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: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService], exports: [PrismaService] }) class 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); });