import * as path from 'path'; import * as fs from 'fs'; 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, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, 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'); // 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/', }); 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 } }); if (photo) { 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; } } } @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, }, }; } @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 user = await this.prisma.user.findUnique({ where: { email } }); if (!user || !(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('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: { include: { uploader: { select: { id: true, name: true } } } }, legs: { orderBy: { sequence: 'asc' }, include: { expenses: { include: { location: { select: { name: true, plannedStart: true } }, paidBy: { select: { name: true } } } }, locations: { orderBy: { plannedStart: 'asc' }, include: { _count: { select: { comments: true } } } }, }, }, }, }); if (!tour) { console.warn(`[PublicTour] Không tìm thấy Tour với ID: ${id} trong Database`); throw new NotFoundException(`Không tìm thấy Tour`); } return tour; } } @Controller('tours') class TourController { constructor( private prisma: PrismaService, @Inject(CACHE_MANAGER) private cacheManager: Cache ) {} @UseGuards(JwtAuthGuard) @Post() async createTour(@Body() body: any, @Req() req: any) { const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags } = body; const tour = await this.prisma.tour.create({ data: { title, description, 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' } }, 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) { 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: body.paidById || null, } }); } // 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) { // Lưu ý: Trong thực tế nên kiểm tra xem user có phải là OWNER không return this.prisma.tour.update({ where: { id }, data: { title: body.title, description: body.description, 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, }, }).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) { 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 --- // 2. Xóa các file vật lý (ảnh 2K) trong thư mục uploads/tours for (const photo of tour.photos) { if (photo.imageUrl) { const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); if (fs.existsSync(displayFilePath)) { fs.unlinkSync(displayFilePath); } } } // Cập nhật DB: Xóa link ảnh 2K vì file vật lý đã bị xóa, tourId sẽ tự động SetNull await this.prisma.photo.updateMany({ where: { tourId: id }, data: { imageUrl: null } }); await this.prisma.tour.delete({ where: { id }, }); // 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`); return { success: true }; } // getPublicTours does not need TourRoleGuard as it's for any logged-in user to see their tours @UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache @UseGuards(JwtAuthGuard) @Get('explore') async getPublicTours(@Req() req: any) { // Lọc Tour: Chỉ lấy những tour mà người dùng hiện tại là thành viên (Participant) return this.prisma.tour.findMany({ where: { participants: { some: { userId: req.user.id } } }, take: 20, include: { participants: { where: { userId: req.user.id }, select: { role: true } }, photos: { take: 1 }, legs: { orderBy: { sequence: 'asc' }, include: { locations: { orderBy: { plannedStart: 'asc' }, include: { _count: { select: { comments: true } } } } } } } }); } @UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache @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: { include: { uploader: { select: { id: true, name: true } } } }, legs: { orderBy: { sequence: 'asc' }, include: { expenses: { include: { location: { select: { name: true, plannedStart: true } }, paidBy: { select: { name: true } } } }, locations: { orderBy: { plannedStart: 'asc' }, include: { _count: { select: { comments: true } } } }, }, }, }, }); if (!tour) 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; 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'; // 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) // 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; } @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can create a join request for themselves or others (if they have permission) @UseGuards(JwtAuthGuard, TourRoleGuard) @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' }, }), ]); 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 participation = await this.prisma.tourParticipant.findUnique({ where: { tourId_userId: { tourId, userId } }, }); if (!participation) { throw new NotFoundException('Thành viên này không có trong tour'); } await this.cacheManager.del(`user-role:${userId}:${tourId}`); await this.prisma.tourParticipant.delete({ where: { tourId_userId: { tourId, userId } }, }); 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 } } }); })); } } @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: '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: '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.'); } // Xóa file 2K (imageUrl) nếu tồn tại if (photo.imageUrl) { const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, '')); if (fs.existsSync(displayFilePath)) { fs.unlinkSync(displayFilePath); } } // Xóa file gốc (originalUrl) nếu tồn tại if (photo.originalUrl) { const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, '')); if (fs.existsSync(originalFilePath)) { fs.unlinkSync(originalFilePath); } } await this.prisma.photo.delete({ where: { id } }); return { message: 'Ảnh đã được xóa thành công.' }; } @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() @UseGuards(AdminGuard) async getAllUsers(@Req() req: any, @Query('q') q?: string) { const currentUserId = req.user?.id; const users = await this.prisma.user.findMany({ where: q ? { OR: [ { name: { contains: q, mode: 'insensitive' as any } }, { email: { contains: q, mode: 'insensitive' as any } }, ], } : undefined, 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 }, 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; } } @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); } } @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 comment = await this.prisma.comment.create({ data: { content: body.content, 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' }, select: { id: true, imageUrl: true, originalUrl: true, capturedAt: true, metadata: 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) { throw new NotFoundException('Không tìm thấy ảnh.'); } const comment = await this.prisma.comment.create({ data: { content: content.trim(), 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; } } @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], 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); });