feat: tính năng chia sẻ khẩn cấp
This commit is contained in:
+397
-19
@@ -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]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user