feat: tính năng chia sẻ khẩn cấp

This commit is contained in:
2026-06-21 12:52:47 +07:00
parent 8d79cd76f6
commit 403c169ddd
21 changed files with 2939 additions and 132 deletions
+397 -19
View File
@@ -409,6 +409,22 @@ class AuthController {
};
}
@UseGuards(JwtAuthGuard)
@Post('promote-admin')
async promoteAdmin(@Body() body: { secretKey: string }, @Req() req: any) {
const { secretKey } = body;
const adminSecret = this.configService.get<string>('ADMIN_SECRET_KEY') || 'yotrip_secret_admin_key';
if (secretKey !== adminSecret) {
throw new BadRequestException('Mã Secret Key không hợp lệ.');
}
const updated = await this.prisma.user.update({
where: { id: req.user.id },
data: { isAdmin: true },
select: { id: true, email: true, name: true, isAdmin: true }
});
return { success: true, user: updated };
}
@Post('create-guest')
async createGuestUser() {
const user = await this.prisma.user.create({
@@ -687,10 +703,12 @@ class TourController {
@Post()
async createTour(@Body() body: any, @Req() req: any) {
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags, members } = body;
const filteredTitle = await filterText(this.prisma, title || '');
const filteredDesc = await filterText(this.prisma, description || '');
const tour = await this.prisma.tour.create({
data: {
title,
description,
title: filteredTitle,
description: filteredDesc,
startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null,
tags: tags || [],
@@ -959,19 +977,24 @@ class TourController {
@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
const updateData: any = {
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,
};
if (body.title !== undefined) {
updateData.title = await filterText(this.prisma, body.title);
}
if (body.description !== undefined) {
updateData.description = await filterText(this.prisma, body.description);
}
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,
},
data: updateData,
}).then(async (tour) => {
await Promise.all([
this.cacheManager.del(id),
@@ -2410,9 +2433,10 @@ class CommentController {
@Body() body: { content: string },
@Req() req: any
) {
const filteredContent = await filterText(this.prisma, body.content);
const comment = await this.prisma.comment.create({
data: {
content: body.content,
content: filteredContent,
locationId,
userId: req.user.id
},
@@ -2800,9 +2824,10 @@ class PublicPhotoController {
throw new NotFoundException('Không tìm thấy ảnh.');
}
const filteredContent = await filterText(this.prisma, content.trim());
const comment = await this.prisma.comment.create({
data: {
content: content.trim(),
content: filteredContent,
photoId,
userId: req.user.id
},
@@ -2819,6 +2844,35 @@ class PublicPhotoController {
return comment;
}
@UseGuards(JwtAuthGuard)
@Delete('comments/:id')
async deletePhotoComment(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const comment = await this.prisma.comment.findUnique({
where: { id },
include: { photo: true }
});
if (!comment) {
throw new NotFoundException('Không tìm thấy bình luận.');
}
const isAuthorized = req.user.isAdmin ||
comment.userId === req.user.id ||
(comment.photo && comment.photo.uploaderId === req.user.id);
if (!isAuthorized) {
throw new ForbiddenException('Bạn không có quyền xóa bình luận này.');
}
await this.prisma.comment.delete({ where: { id } });
if (comment.photoId) {
this.commentGateway.server.to(`photo_${comment.photoId}`).emit('photoCommentDeleted', { id, photoId: comment.photoId });
}
return { success: true, message: 'Đã xóa bình luận thành công.' };
}
@Get(':photoId/share')
async sharePhoto(
@Param('photoId', ParseUUIDPipe) photoId: string,
@@ -3185,11 +3239,12 @@ class DirectMessageController {
throw new ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
}
const filteredContent = content ? await filterText(this.prisma, content) : '';
const message = await this.prisma.directMessage.create({
data: {
senderId,
receiverId,
content: content || '',
content: filteredContent,
attachmentUrl,
latitude,
longitude
@@ -3289,11 +3344,12 @@ class TourMessageController {
throw new ForbiddenException('Bạn không phải là thành viên của hành trình này.');
}
const filteredContent = content ? await filterText(this.prisma, content) : '';
const tourMessage = await this.prisma.tourMessage.create({
data: {
tourId,
senderId,
content: content || '',
content: filteredContent,
attachmentUrl,
latitude,
longitude
@@ -3333,13 +3389,14 @@ class TourMessageController {
for (const p of otherParticipants) {
if (p.userId) {
const filteredContent = content ? await filterText(this.prisma, content) : '';
const isTagged = hasTags && taggedUserIds.includes(p.userId);
console.log(`[sendTourMessage] Emitting tourMessageNotification to user_${p.userId}, isTagged: ${isTagged}`);
this.commentGateway.server.to(`user_${p.userId}`).emit('tourMessageNotification', {
tourId,
senderName: req.user.name || 'Thành viên',
tourTitle: tour?.title || 'Hành trình',
content: isTagged ? `@Bạn: ${content || ''}` : content || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
content: isTagged ? `@Bạn: ${filteredContent || ''}` : filteredContent || (attachmentUrl ? '[Hình ảnh]' : '[Vị trí]'),
isTagged
});
}
@@ -3349,6 +3406,327 @@ class TourMessageController {
}
}
async function filterText(prisma: PrismaService, text: string): Promise<string> {
if (!text) return text;
try {
const filters = await prisma.wordFilter.findMany();
let result = text;
for (const filter of filters) {
const escapedWord = filter.word.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(escapedWord, 'gi');
result = result.replace(regex, filter.replacement);
}
return result;
} catch (err) {
console.error('Lỗi khi lọc từ cấm:', err);
return text;
}
}
@Controller('moderation')
class ModerationController {
constructor(private prisma: PrismaService) {}
@Get('settings')
async getSettings() {
let settings = await this.prisma.moderationSetting.findFirst();
if (!settings) {
settings = await this.prisma.moderationSetting.create({
data: { blockNsfw: false, blurFaces: false }
});
}
return settings;
}
}
@Controller('admin/moderation')
@UseGuards(JwtAuthGuard, AdminGuard)
class AdminModerationController {
constructor(private prisma: PrismaService) {}
@Get()
async getSettings() {
let settings = await this.prisma.moderationSetting.findFirst();
if (!settings) {
settings = await this.prisma.moderationSetting.create({
data: { blockNsfw: false, blurFaces: false }
});
}
return settings;
}
@Post()
async updateSettings(@Body() body: { blockNsfw?: boolean, blurFaces?: boolean }) {
let settings = await this.prisma.moderationSetting.findFirst();
if (!settings) {
return this.prisma.moderationSetting.create({
data: {
blockNsfw: body.blockNsfw ?? false,
blurFaces: body.blurFaces ?? false
}
});
}
return this.prisma.moderationSetting.update({
where: { id: settings.id },
data: {
blockNsfw: body.blockNsfw,
blurFaces: body.blurFaces
}
});
}
@Get('word-filters')
async getWordFilters() {
return this.prisma.wordFilter.findMany({
orderBy: { createdAt: 'desc' }
});
}
@Post('word-filters')
async addWordFilter(@Body() body: { word: string, replacement: string }) {
const { word, replacement } = body;
if (!word || replacement === undefined) {
throw new BadRequestException('Từ khóa và từ thay thế không được để trống.');
}
return this.prisma.wordFilter.create({
data: {
word: word.trim(),
replacement: replacement.trim()
}
});
}
@Delete('word-filters/:id')
async deleteWordFilter(@Param('id', ParseUUIDPipe) id: string) {
await this.prisma.wordFilter.delete({ where: { id } });
return { success: true };
}
}
@Controller('users/trusted')
class TrustedUsersController {
constructor(private prisma: PrismaService) {}
@Get()
async getTrustedUsers() {
const ratings = await this.prisma.tourRating.groupBy({
by: ['targetUserId'],
_avg: { averageScore: true },
_count: { id: true }
});
const users = await this.prisma.user.findMany({
where: { id: { in: ratings.map(r => r.targetUserId) } },
select: { id: true, name: true, avatar: true }
});
const result = ratings.map(r => {
const u = users.find(user => user.id === r.targetUserId);
return {
id: r.targetUserId,
name: u?.name || 'Ẩn danh',
avatar: u?.avatar || null,
averageScore: Math.round((r._avg.averageScore || 0) * 10) / 10,
ratingCount: r._count.id
};
});
return result.sort((a, b) => b.averageScore - a.averageScore || b.ratingCount - a.ratingCount);
}
}
@Controller('tours/:tourId/ratings')
@UseGuards(JwtAuthGuard)
class TourRatingController {
constructor(private prisma: PrismaService) {}
@Get()
async getTourRatings(@Param('tourId', ParseUUIDPipe) tourId: string) {
return this.prisma.tourRating.findMany({
where: { tourId },
include: {
raterUser: { select: { id: true, name: true, avatar: true } },
targetUser: { select: { id: true, name: true, avatar: true } }
},
orderBy: { createdAt: 'desc' }
});
}
@Post()
async createTourRating(
@Param('tourId', ParseUUIDPipe) tourId: string,
@Body() body: {
targetUserId: string;
honesty: number;
transparency: number;
enthusiasm: number;
cheerfulness: number;
seriousness: number;
planning: number;
survival: number;
comment?: string;
},
@Req() req: any
) {
const raterUserId = req.user.id;
const { targetUserId, honesty, transparency, enthusiasm, cheerfulness, seriousness, planning, survival, comment } = body;
if (!targetUserId || honesty < 1 || honesty > 5 || transparency < 1 || transparency > 5 || enthusiasm < 1 || enthusiasm > 5 || cheerfulness < 1 || cheerfulness > 5 || seriousness < 1 || seriousness > 5 || planning < 1 || planning > 5 || survival < 1 || survival > 5) {
throw new BadRequestException('Dữ liệu đánh giá không hợp lệ.');
}
const participant = await this.prisma.tourParticipant.findFirst({
where: { tourId, userId: raterUserId }
});
if (!participant) {
throw new ForbiddenException('Bạn không phải là thành viên của tour này.');
}
const targetParticipant = await this.prisma.tourParticipant.findFirst({
where: {
tourId,
userId: targetUserId,
role: { in: ['OWNER', 'MANAGER'] }
}
});
if (!targetParticipant) {
throw new BadRequestException('Chỉ có thể đánh giá người tạo tour hoặc người quản lý.');
}
const averageScore = (honesty + transparency + enthusiasm + cheerfulness + seriousness + planning + survival) / 7;
const existingRating = await this.prisma.tourRating.findUnique({
where: {
tourId_targetUserId_raterUserId: {
tourId,
targetUserId,
raterUserId
}
}
});
if (existingRating) {
return this.prisma.tourRating.update({
where: { id: existingRating.id },
data: {
honesty,
transparency,
enthusiasm,
cheerfulness,
seriousness,
planning,
survival,
averageScore,
comment,
createdAt: new Date()
}
});
}
return this.prisma.tourRating.create({
data: {
tourId,
targetUserId,
raterUserId,
honesty,
transparency,
enthusiasm,
cheerfulness,
seriousness,
planning,
survival,
averageScore,
comment
}
});
}
}
@Controller('tours')
class PublicTourShareController {
constructor(private prisma: PrismaService) {}
@Get('share/:token')
async getSharedJourney(@Param('token') token: string) {
const tourShare = await this.prisma.tourShare.findUnique({
where: { token, isEnabled: true },
include: {
tour: {
include: {
creator: { select: { id: true, name: true, phone: true } },
participants: {
select: {
id: true,
role: true,
displayName: true,
user: { select: { id: true, name: true, phone: true } }
}
},
legs: {
include: {
locations: {
orderBy: { plannedStart: 'asc' }
}
},
orderBy: { sequence: 'asc' }
}
}
}
}
});
if (!tourShare || !tourShare.tour) {
throw new NotFoundException('Không tìm thấy hành trình chia sẻ hoặc liên kết đã bị vô hiệu hóa.');
}
return {
tour: tourShare.tour,
isEnabled: tourShare.isEnabled
};
}
}
@Controller('tours/:tourId/share')
@UseGuards(JwtAuthGuard)
class TourShareController {
constructor(private prisma: PrismaService) {}
@Get()
async getShareStatus(@Param('tourId', ParseUUIDPipe) tourId: string) {
let share = await this.prisma.tourShare.findUnique({
where: { tourId }
});
if (!share) {
share = await this.prisma.tourShare.create({
data: { tourId, isEnabled: false }
});
}
return share;
}
@Post()
async toggleShare(
@Param('tourId', ParseUUIDPipe) tourId: string,
@Body() body: { isEnabled: boolean }
) {
let share = await this.prisma.tourShare.findUnique({
where: { tourId }
});
if (!share) {
return this.prisma.tourShare.create({
data: {
tourId,
isEnabled: body.isEnabled
}
});
}
return this.prisma.tourShare.update({
where: { id: share.id },
data: { isEnabled: body.isEnabled }
});
}
}
@Module({
imports: [
ConfigModule.forRoot({
@@ -3372,7 +3750,7 @@ class TourMessageController {
signOptions: { expiresIn: '1d' },
}) as any,
],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController, ModerationController, AdminModerationController, TrustedUsersController, TourRatingController, PublicTourShareController, TourShareController],
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
exports: [PrismaService]
})