import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query } from '@nestjs/common'; import { PrismaService } from './prisma.service.js'; import 'dotenv/config'; import * as bcrypt from 'bcrypt'; import { AdminGuard } from './admin.guard.js'; import { JwtModule, JwtService } from '@nestjs/jwt'; import { JwtAuthGuard } from './jwt-auth.guard.js'; import { JwtStrategy } from './jwt.strategy.js'; import { TourRoleGuard } from './rbac.middleware.js'; @Controller() class AppController { @Get() getHello(): string { return 'Travel Planning API is running!'; } } @Controller('v1/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('v1/tours') class TourController { constructor(private prisma: PrismaService) {} @UseGuards(JwtAuthGuard) @Post() async createTour(@Body() body: any, @Req() req: any) { const { title, startDate, endDate } = body; return this.prisma.tour.create({ data: { title, startDate: startDate ? new Date(startDate) : null, endDate: endDate ? new Date(endDate) : null, createdById: req.user.id, participants: { create: { userId: req.user.id, role: 'OWNER' } }, legs: { create: { sequence: 1, note: 'Chặng khởi đầu' } } } }); } @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, } }); } return loc; }); } @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/start-point') async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { const { latitude, longitude, name } = 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), } }); } @UseGuards(JwtAuthGuard, TourRoleGuard) @Post(':tourId/end-point') async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) { const { latitude, longitude, name } = 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, plannedEnd: new Date(0), // Đánh dấu đây là điểm kết thúc đặc biệt } }); } @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 } }); } return allLegs; } @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}` } }); } @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, startDate: body.startDate ? new Date(body.startDate) : undefined, endDate: body.endDate ? new Date(body.endDate) : undefined, }, }); } @UseGuards(JwtAuthGuard, TourRoleGuard) @Delete(':id') async deleteTour(@Param('id', ParseUUIDPipe) id: string) { // Xóa tour sẽ xóa cascade các Leg, Location, Expense nhờ config onDelete: Cascade trong schema await this.prisma.tour.delete({ where: { id }, }); return { success: true }; } @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: { photos: { take: 1 }, legs: { orderBy: { sequence: 'asc' }, include: { locations: { orderBy: { plannedStart: 'asc' } } } } } }); } @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: { locations: { orderBy: { plannedStart: 'asc' } }, }, }, }, }); if (!tour) throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`); return tour; } @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'; 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 } } }, }); } return this.prisma.tourParticipant.create({ data: { tourId, userId: body.userId, role, }, include: { user: { select: { id: true, name: true, email: true } } }, }); } @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.prisma.tourParticipant.delete({ where: { tourId_userId: { tourId, userId } }, }); return { message: 'Đã xóa thành viên khỏi tour' }; } } @Controller('v1/locations') @UseGuards(JwtAuthGuard) class LocationController { constructor(private prisma: PrismaService) {} @Patch(':id') async updateLocation(@Param('id', ParseUUIDPipe) id: string, @Body() body: 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}` } }); } } return location; } @Delete(':id') async deleteLocation(@Param('id', ParseUUIDPipe) id: string) { await this.prisma.location.delete({ where: { id } }); return { success: true }; } } @Controller('v1/legs') @UseGuards(JwtAuthGuard) class LegController { constructor(private prisma: PrismaService) {} @Patch(':id') async updateLeg(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) { return this.prisma.leg.update({ where: { id }, data: { note: body.note, sequence: body.sequence } }); } @Delete(':id') 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.'); } await this.prisma.leg.delete({ where: { id } }); 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('v1/routing') class RoutingController { constructor(private prisma: PrismaService) {} @Post('optimize/:legId') async optimize(@Param('legId', ParseUUIDPipe) legId: string) { 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' } }); return { locations: updatedLocations, totalDistance: parseFloat(totalDistance.toFixed(2)) }; } } @Controller('v1/users') 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); } @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 } }); } @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'); } await this.prisma.tourParticipant.deleteMany({ where: { userId: id } }); await this.prisma.user.delete({ where: { id } }); return { message: 'Đã xóa người dùng' }; } @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; } } @Module({ imports: [ JwtModule.register({ secret: process.env.JWT_SECRET || 'super-secret', signOptions: { expiresIn: '1d' }, }), ], controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController], providers: [PrismaService, JwtStrategy, TourRoleGuard], exports: [PrismaService] }) class AppModule {} async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableCors(); // Cho phép Frontend gọi API app.setGlobalPrefix('api'); // Tất cả API sẽ bắt đầu bằng /api/... const port = process.env.PORT || 3001; await app.listen(port); console.log(`🚀 Server is running on: http://localhost:${port}`); } bootstrap();