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:
Vendored
+1
@@ -19,6 +19,7 @@ export declare class EmailService {
|
||||
private transporter;
|
||||
constructor();
|
||||
sendOTP(email: string, otp: string): Promise<any>;
|
||||
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
|
||||
}
|
||||
export declare class CommentGateway implements OnGatewayConnection {
|
||||
server: Server;
|
||||
|
||||
Vendored
+283
-15
@@ -51,6 +51,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CommentGateway = exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
|
||||
const path = __importStar(require("path"));
|
||||
const fs = __importStar(require("fs"));
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const config_1 = require("@nestjs/config");
|
||||
require("reflect-metadata");
|
||||
const zlib = __importStar(require("zlib"));
|
||||
@@ -258,6 +259,40 @@ let EmailService = class EmailService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async sendTourInvitation(email, tourTitle, inviteLink) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
exports.EmailService = EmailService;
|
||||
exports.EmailService = EmailService = __decorate([
|
||||
@@ -352,7 +387,7 @@ let AuthController = class AuthController {
|
||||
async login(body) {
|
||||
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 common_1.UnauthorizedException('Email hoặc mật khẩu không chính xác');
|
||||
}
|
||||
if (user.isBlocked) {
|
||||
@@ -369,6 +404,67 @@ let AuthController = class AuthController {
|
||||
},
|
||||
};
|
||||
}
|
||||
async googleLogin(body) {
|
||||
const { credential } = body;
|
||||
if (!credential) {
|
||||
throw new common_1.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 common_1.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 common_1.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 common_1.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 common_1.UnauthorizedException || error instanceof common_1.BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
throw new common_1.BadRequestException('Đã xảy ra lỗi khi đăng nhập bằng Google.');
|
||||
}
|
||||
}
|
||||
async signupRequest(body) {
|
||||
const { email, password, name, phone, address } = body;
|
||||
const existingUser = await this.prisma.user.findUnique({ where: { email } });
|
||||
@@ -465,6 +561,13 @@ __decorate([
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "login", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('google'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "googleLogin", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('signup/request'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
@@ -550,8 +653,9 @@ PublicTourController = __decorate([
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], PublicTourController);
|
||||
let TourController = class TourController {
|
||||
constructor(prisma, cacheManager) {
|
||||
constructor(prisma, emailService, cacheManager) {
|
||||
this.prisma = prisma;
|
||||
this.emailService = emailService;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
async createTour(body, req) {
|
||||
@@ -844,16 +948,14 @@ let TourController = class TourController {
|
||||
}
|
||||
async getPublicTours(req) {
|
||||
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: {
|
||||
@@ -1245,6 +1347,142 @@ let TourController = class TourController {
|
||||
});
|
||||
}));
|
||||
}
|
||||
async createInvitation(tourId, body, req) {
|
||||
const { email, role = client_1.ParticipantRole.MEMBER } = body;
|
||||
if (!email) {
|
||||
throw new common_1.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 common_1.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 common_1.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);
|
||||
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 common_1.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!' };
|
||||
}
|
||||
async joinByToken(body, req) {
|
||||
const { token } = body;
|
||||
if (!token) {
|
||||
throw new common_1.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 common_1.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 common_1.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) { }
|
||||
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}"!` };
|
||||
}
|
||||
async mergeMember(tourId, body, req) {
|
||||
const { manualParticipantId, systemUserId } = body;
|
||||
if (!manualParticipantId || !systemUserId) {
|
||||
throw new common_1.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 common_1.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 common_1.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 !== client_1.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.' };
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
@@ -1337,7 +1575,6 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "getPublicTours", null);
|
||||
__decorate([
|
||||
(0, common_1.UseInterceptors)(compress_cache_interceptor_1.CompressCacheInterceptor),
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
|
||||
(0, common_1.Get)(':id'),
|
||||
@@ -1380,8 +1617,7 @@ __decorate([
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "getJoinRequests", null);
|
||||
__decorate([
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(0, common_1.Post)(':tourId/join-requests'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
@@ -1434,10 +1670,42 @@ __decorate([
|
||||
__metadata("design:paramtypes", [String, Array, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "uploadPhotos", null);
|
||||
__decorate([
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
|
||||
(0, common_1.Post)(':tourId/invitations'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
__param(2, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "createInvitation", null);
|
||||
__decorate([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(0, common_1.Post)('join-by-token'),
|
||||
__param(0, (0, common_1.Body)()),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "joinByToken", null);
|
||||
__decorate([
|
||||
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
|
||||
(0, common_1.Post)(':tourId/members/merge'),
|
||||
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
__param(2, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "mergeMember", null);
|
||||
TourController = __decorate([
|
||||
(0, common_1.Controller)('tours'),
|
||||
__param(1, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService, Object])
|
||||
__param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
EmailService, Object])
|
||||
], TourController);
|
||||
let LocationController = class LocationController {
|
||||
constructor(prisma, cacheManager) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourInvitation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiredAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TourInvitation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TourInvitation_token_key" ON "TourInvitation"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TourInvitation_tourId_email_key" ON "TourInvitation"("tourId", "email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourInvitation" ADD CONSTRAINT "TourInvitation_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -99,6 +99,7 @@ model Tour {
|
||||
joinRequests JoinRequest[]
|
||||
legs Leg[]
|
||||
photos Photo[]
|
||||
invitations TourInvitation[]
|
||||
}
|
||||
|
||||
model JoinRequest {
|
||||
@@ -213,3 +214,16 @@ model Comment {
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TourInvitation {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
email String
|
||||
role ParticipantRole @default(MEMBER)
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiredAt DateTime
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([tourId, email])
|
||||
}
|
||||
|
||||
+288
-14
@@ -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 hiện tại là thành viên (Participant)
|
||||
// Trả về tất cả các tour trong hệ thống để hiển 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')
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 459 KiB |
Reference in New Issue
Block a user