import * as dotenv from 'dotenv'; import * as path from 'path'; // Sửa đường dẫn: lùi 2 cấp từ backend/src để ra root monorepo const envPath = path.resolve(process.cwd(), '..', '.env'); dotenv.config({ path: envPath }); import 'reflect-metadata'; import * as fs from 'fs'; 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 } 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 { 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: ' + envPath); } // 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; } } @Controller() class AppController { @Get() getHello(): string { return 'Travel Planning API is running!'; } } @Controller('auth') class AuthController { constructor(private prisma: PrismaService, private jwtService: JwtService) {} @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') async signup(@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 userCount = await this.prisma.user.count(); const shouldBeAdmin = userCount === 0; const passwordHash = await bcrypt.hash(password, 10); return 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 } }); } } @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; } } @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], providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector], exports: [PrismaService] }) class AppModule {} bootstrap().catch(err => { console.error('💥 Lỗi khởi động Server:'); console.error(err); process.exit(1); });