bugs: lỗi khi người dùng link để tham gia tour nhưng không xuất hiện trong danh sách thành viên

This commit is contained in:
2026-06-20 20:59:01 +07:00
parent 52706cab7d
commit 36157bd53b
18 changed files with 1419 additions and 188 deletions
+288 -14
View File
@@ -1,5 +1,6 @@
import * as path from 'path';
import * as fs from 'fs';
import * as crypto from 'crypto';
import { ConfigModule, ConfigService } from '@nestjs/config';
import 'reflect-metadata';
@@ -244,6 +245,42 @@ export class EmailService {
throw error;
}
}
async sendTourInvitation(email: string, tourTitle: string, inviteLink: 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: `Lời mời tham gia hành trình: ${tourTitle}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 10px;">
<h2 style="color: #2563eb; text-align: center;">Lời Mời Tham Gia Hành Trình</h2>
<p>Xin chào,</p>
<p>Bạn đã được mời tham gia hành trình du lịch <strong>"${tourTitle}"</strong>.</p>
<p>Vui lòng nhấp vào nút dưới đây để chấp nhận lời mời và gia nhập hành trình của chúng tôi:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${inviteLink}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; font-weight: bold; border-radius: 8px; display: inline-block;">
Tham Gia Hành Trình Ngay
</a>
</div>
<p style="font-size: 13px; color: #4b5563;">Hoặc bạn có thể sao chép liên kết dưới đây và dán vào trình duyệt:</p>
<p style="font-size: 13px; color: #2563eb; word-break: break-all;">${inviteLink}</p>
<p>Lời mời này có hiệu lực trong vòng 7 ngày. Hãy nhanh tay đăng ký nhé!</p>
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;" />
<p style="font-size: 12px; color: #6b7280; text-align: center;">Đây là email tự động từ ứng dụng hệ thống Travel Planning.</p>
</div>
`,
};
try {
return await this.transporter.sendMail(mailOptions);
} catch (error) {
throw error;
}
}
}
@Controller()
@@ -355,7 +392,7 @@ class AuthController {
const { email, password } = body;
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Email hoặc mật khẩu không chính xác');
}
@@ -377,6 +414,72 @@ class AuthController {
};
}
@Post('google')
async googleLogin(@Body() body: { credential: string }) {
const { credential } = body;
if (!credential) {
throw new BadRequestException('Vui lòng cung cấp Google credential.');
}
try {
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
if (!response.ok) {
throw new UnauthorizedException('Token Google không hợp lệ hoặc đã hết hạn.');
}
const payload = await response.json();
const email = payload.email;
const name = payload.name || email.split('@')[0];
const avatar = payload.picture || null;
if (!email) {
throw new BadRequestException('Không tìm thấy địa chỉ email từ Google.');
}
let user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
user = await this.prisma.user.create({
data: {
email,
name,
avatar,
isAnonymous: false,
},
});
} else 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.');
} else if (user.isAnonymous) {
user = await this.prisma.user.update({
where: { id: user.id },
data: {
isAnonymous: false,
name: user.name || name,
avatar: user.avatar || avatar,
},
});
}
const jwtPayload = { email: user.email, sub: user.id };
return {
access_token: this.jwtService.sign(jwtPayload),
user: {
id: user.id,
email: user.email,
name: user.name,
avatar: user.avatar,
isAdmin: user.isAdmin,
},
};
} catch (error) {
console.error('[Google Auth Error]:', error);
if (error instanceof UnauthorizedException || error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException('Đã xảy ra lỗi khi đăng nhập bằng Google.');
}
}
@Post('signup/request')
async signupRequest(@Body() body: any) {
const { email, password, name, phone, address } = body;
@@ -525,6 +628,7 @@ class PublicTourController {
class TourController {
constructor(
private prisma: PrismaService,
private emailService: EmailService,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {}
@@ -894,23 +998,20 @@ class TourController {
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 hin tại là thành viên (Participant)
// Trả về tất cả các tour trong hệ thống để hin thị trên bản đồ cộng đồng
return this.prisma.tour.findMany({
where: {
participants: {
some: { userId: req.user.id }
}
},
take: 20,
take: 50,
include: {
participants: {
where: { userId: req.user.id },
select: { role: true }
select: { userId: true, role: true, displayName: true }
},
joinRequests: {
where: { userId: req.user.id, status: 'PENDING' },
select: { id: true }
},
photos: { take: 1 },
legs: {
@@ -929,7 +1030,6 @@ class TourController {
});
}
@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')
@@ -1133,8 +1233,7 @@ class TourController {
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)
@UseGuards(JwtAuthGuard)
@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;
@@ -1392,6 +1491,181 @@ class TourController {
});
}));
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER)
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/invitations')
async createInvitation(
@Param('tourId', ParseUUIDPipe) tourId: string,
@Body() body: { email: string; role?: ParticipantRole },
@Req() req: any
) {
const { email, role = ParticipantRole.MEMBER } = body;
if (!email) {
throw new BadRequestException('Vui lòng cung cấp email mời.');
}
const tour = await this.prisma.tour.findUnique({
where: { id: tourId },
select: { title: true }
});
if (!tour) {
throw new NotFoundException('Không tìm thấy hành trình.');
}
const invitedUser = await this.prisma.user.findUnique({ where: { email } });
if (invitedUser) {
const existingParticipant = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: invitedUser.id } }
});
if (existingParticipant) {
throw new BadRequestException('Người dùng này đã là thành viên của tour.');
}
}
const token = crypto.randomBytes(32).toString('hex');
const expiredAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const invitation = await this.prisma.tourInvitation.upsert({
where: { tourId_email: { tourId, email } },
update: { token, expiredAt, role },
create: { tourId, email, token, expiredAt, role }
});
const appUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const inviteLink = `${appUrl}/join-tour?token=${token}`;
try {
await this.emailService.sendTourInvitation(email, tour.title, inviteLink);
} catch (e) {
console.error('[Invitation Email Error]:', e);
throw new BadRequestException('Không thể gửi email lời mời. Vui lòng kiểm tra cấu hình SMTP.');
}
return { success: true, message: 'Lời mời đã được gửi thành công!' };
}
@UseGuards(JwtAuthGuard)
@Post('join-by-token')
async joinByToken(@Body() body: { token: string }, @Req() req: any) {
const { token } = body;
if (!token) {
throw new BadRequestException('Vui lòng cung cấp token lời mời.');
}
const invitation = await this.prisma.tourInvitation.findUnique({
where: { token },
include: { tour: { select: { title: true } } }
});
if (!invitation) {
throw new NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
}
if (invitation.expiredAt < new Date()) {
try {
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
} catch (e) {}
throw new BadRequestException('Lời mời đã hết hạn.');
}
const userId = req.user.id;
const existingParticipation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId: invitation.tourId, userId } }
});
if (existingParticipation) {
try {
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
} catch (e) {}
// Xóa cache để đảm bảo dữ liệu thành viên luôn mới nhất
await Promise.all([
this.cacheManager.del(invitation.tourId),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}`),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}/public`),
this.cacheManager.del(`/api/v1/tours/explore`),
]);
return { success: true, tourId: invitation.tourId, message: 'Bạn đã là thành viên của tour này.' };
}
await this.prisma.$transaction([
this.prisma.tourParticipant.create({
data: {
tourId: invitation.tourId,
userId,
role: invitation.role
}
}),
this.prisma.tourInvitation.delete({
where: { id: invitation.id }
})
]);
await Promise.all([
this.cacheManager.del(invitation.tourId),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}`),
this.cacheManager.del(`/api/v1/tours/${invitation.tourId}/public`),
this.cacheManager.del(`/api/v1/tours/explore`),
this.cacheManager.del(`user-role:${userId}:${invitation.tourId}`),
]);
return { success: true, tourId: invitation.tourId, message: `Bạn đã gia nhập hành trình "${invitation.tour.title}"!` };
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER)
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/members/merge')
async mergeMember(
@Param('tourId', ParseUUIDPipe) tourId: string,
@Body() body: { manualParticipantId: string; systemUserId: string },
@Req() req: any
) {
const { manualParticipantId, systemUserId } = body;
if (!manualParticipantId || !systemUserId) {
throw new BadRequestException('Thiếu thông tin manualParticipantId hoặc systemUserId.');
}
const manualParticipant = await this.prisma.tourParticipant.findFirst({
where: { id: manualParticipantId, tourId, userId: null }
});
if (!manualParticipant) {
throw new NotFoundException('Không tìm thấy thành viên ngoài hệ thống cần gán.');
}
const systemParticipant = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: systemUserId } }
});
if (!systemParticipant) {
throw new NotFoundException('Không tìm thấy thành viên hệ thống trong hành trình này.');
}
const nextAdult = Math.max(systemParticipant.adultCount, manualParticipant.adultCount);
const nextChild = Math.max(systemParticipant.childCount, manualParticipant.childCount);
await this.prisma.$transaction([
this.prisma.tourParticipant.update({
where: { id: systemParticipant.id },
data: {
adultCount: nextAdult,
childCount: nextChild,
role: manualParticipant.role !== ParticipantRole.MEMBER ? manualParticipant.role : systemParticipant.role
}
}),
this.prisma.tourParticipant.delete({
where: { id: manualParticipant.id }
})
]);
await this.cacheManager.del(`user-role:${systemUserId}:${tourId}`);
await Promise.all([
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/explore`)
]);
return { success: true, message: 'Đã gán và hợp nhất thông tin thành viên thành công.' };
}
}
@Controller('locations')