import * as dotenv from 'dotenv'; import * as path from 'path'; import * as fs from 'fs'; // Tự động tìm kiếm file .env ở nhiều vị trí để đảm bảo tải đúng trong môi trường monorepo const possibleEnvPaths = [ path.resolve(process.cwd(), '.env'), path.resolve(process.cwd(), '..', '.env'), path.resolve(__dirname, '..', '.env'), path.resolve(__dirname, '..', '..', '.env'), ]; let loadedEnvPath: string | null = null; for (const p of possibleEnvPaths) { if (fs.existsSync(p)) { dotenv.config({ path: p }); console.log(`[Config] 📂 Biến môi trường được tải từ: ${p}`); loadedEnvPath = p; break; } } import 'reflect-metadata'; import * as zlib from 'zlib'; import { promisify } from 'util'; import sharp from 'sharp'; 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() { if (!process.env.DATABASE_URL) { throw new Error('❌ DATABASE_URL không tồn tại trong biến môi trường! Kiểm tra file .env tại: ' + (loadedEnvPath || 'một trong các đường dẫn đã thử: ' + possibleEnvPaths.join(', '))); } // Kiểm tra log xem biến môi trường đã nhận đúng chưa console.log('===================================='); console.log('DATABASE_URL:', process.env.DATABASE_URL); console.log('===================================='); // 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) 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, private emailService: EmailService, @Inject(CACHE_MANAGER) private cacheManager: Cache ) {} @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 }) { const { email, otp } = 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 userCount = await this.prisma.user.count(); const shouldBeAdmin = userCount === 0; const passwordHash = await bcrypt.hash(password, 10); const 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: 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: 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 }); return Promise.all(files.map(async (file) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const extension = path.extname(file.originalname).toLowerCase() || '.jpg'; const filename = `${uniqueSuffix}${extension}`; const originalFilePath = path.join(memberOriginalDir, filename); const displayFilePath = path.join(tourDisplayPath, filename); // 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. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px) // Sử dụng Sharp để resize và tối ưu dung lượng ảnh await sharp(file.buffer) .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); // 3. Lưu thông tin vào Database (Lưu cả 2 đường dẫn) return this.prisma.photo.create({ data: { tourId: tourId, uploaderId: uploaderId, imageUrl: `/uploads/tours/${filename}`, // URL ảnh 2K dùng để render originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`, // URL ảnh gốc để tải xuống privacy: 'TOUR_ONLY', } }); })); } } @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) {} @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 mới có quyền xóa ảnh của họ if (photo.uploaderId !== req.user.id) { 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.' }; } } @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?.sub; 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) { if (data.password) { data.passwordHash = await bcrypt.hash(data.password, 10); delete data.password; } 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') async deleteUser(@Param('id', ParseUUIDPipe) id: string) { 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') async toggleBlock(@Param('id', ParseUUIDPipe) id: string) { const user = await this.prisma.user.findUnique({ where: { id } }); if (!user) throw new NotFoundException('Người dùng không tồn tại'); 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}`); } 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); } } @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 }); } } } } @Module({ imports: [ 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, AdminOtpController], providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService], exports: [PrismaService] }) class AppModule {} bootstrap().catch(err => { console.error('💥 Lỗi khởi động Server:'); console.error(err); process.exit(1); });