import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req } 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'; @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 } = body; // 1. Kiểm tra email tồn tại const existingUser = await this.prisma.user.findUnique({ where: { email } }); if (existingUser) throw new BadRequestException('Email đã được sử dụng'); // 2. Logic "First User is Admin" const userCount = await this.prisma.user.count(); const shouldBeAdmin = userCount === 0; // 3. Hash mật khẩu const passwordHash = await bcrypt.hash(password, 10); return this.prisma.user.create({ // Bỏ createdTours: {} vì đây là quan hệ 1-n, Prisma sẽ tự hiểu là mảng rỗng data: { email, passwordHash, name, isAdmin: shouldBeAdmin }, select: { id: true, email: true, name: 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) @Post(':tourId/locations') async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) { const legId = body.legId; 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, } }); } @UseGuards(JwtAuthGuard) @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) @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) @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 }; } @Get('explore') async getPublicTours() { // Lấy các tour có ít nhất 1 ảnh và thông tin vị trí từ chặng đầu tiên return this.prisma.tour.findMany({ take: 20, include: { photos: { take: 1 }, legs: { take: 1, include: { locations: { take: 1 } } } } }); } @Get(':id') async getTourDetails(@Param('id', ParseUUIDPipe) id: string) { const tour = await this.prisma.tour.findUnique({ where: { id }, include: { participants: true, photos: true, legs: { orderBy: { sequence: 'asc' }, include: { locations: { orderBy: { plannedStart: 'asc' } }, expenses: true, }, }, }, }); if (!tour) throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`); return tour; } } @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 locations = await this.prisma.location.findMany({ where: { legId }, }); if (locations.length <= 2) return locations; // 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 = 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; 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') @UseGuards(JwtAuthGuard, AdminGuard) class UserController { constructor(private prisma: PrismaService) {} @Get() async getAllUsers() { return this.prisma.user.findMany({ select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true } }); } @Patch(':id') async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) { // Nếu đổi mật khẩu thì cần hash 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) { // Không cho phép tự xóa chính mình hoặc xóa admin cuối cùng (logic đơn giản) const user = await this.prisma.user.findUnique({ where: { id } }); 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.user.delete({ where: { id } }); return { success: true }; } @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'); return this.prisma.user.update({ where: { id }, data: { isBlocked: !user.isBlocked } }); } } @Module({ imports: [ JwtModule.register({ secret: process.env.JWT_SECRET || 'super-secret', signOptions: { expiresIn: '1d' }, }), ], controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController], providers: [PrismaService, JwtStrategy], 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();