feat: tíng năng trò chuyện giữa các thành viên và tin nhắn trực tiếp cho bạn bè
This commit is contained in:
+484
-31
@@ -39,6 +39,55 @@ const gunzip = promisify(zlib.gunzip);
|
||||
// Khai báo vị trí thư mục upload cụ thể
|
||||
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
|
||||
|
||||
@WebSocketGateway({ cors: { origin: '*' } })
|
||||
export class CommentGateway implements OnGatewayConnection {
|
||||
@WebSocketServer() server: Server;
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
console.log(`[WS] Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinTour')
|
||||
handleJoinTour(client: Socket, tourId: string) {
|
||||
client.join(`tour_${tourId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinPhoto')
|
||||
handleJoinPhoto(client: Socket, photoId: string) {
|
||||
client.join(`photo_${photoId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
|
||||
}
|
||||
|
||||
notifyNewComment(tourId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Tour này
|
||||
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
|
||||
}
|
||||
|
||||
notifyNewPhotoComment(photoId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Ảnh này
|
||||
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinUser')
|
||||
handleJoinUser(client: Socket, userId: string) {
|
||||
client.join(`user_${userId}`);
|
||||
console.log(`[WS] Client ${client.id} joined user room: user_${userId}`);
|
||||
}
|
||||
|
||||
notifyNewMessage(receiverId: string, data: any) {
|
||||
this.server.to(`user_${receiverId}`).emit('messageReceived', data);
|
||||
}
|
||||
|
||||
notifyConnectionAccepted(requesterId: string, data: any) {
|
||||
this.server.to(`user_${requesterId}`).emit('connectionAccepted', data);
|
||||
}
|
||||
|
||||
notifyJoinRequestAccepted(userId: string, data: any) {
|
||||
this.server.to(`user_${userId}`).emit('joinRequestAccepted', data);
|
||||
}
|
||||
}
|
||||
|
||||
// Cấu hình TTL (mili giây) cho từng loại dữ liệu
|
||||
const CACHE_TTL = {
|
||||
DEFAULT: 600000, // 10 phút mặc định
|
||||
@@ -469,6 +518,7 @@ class AuthController {
|
||||
name: user.name,
|
||||
avatar: user.avatar,
|
||||
isAdmin: user.isAdmin,
|
||||
isGoogle: true,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -629,7 +679,8 @@ class TourController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailService: EmailService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -1320,6 +1371,17 @@ class TourController {
|
||||
}),
|
||||
]);
|
||||
|
||||
const tour = await this.prisma.tour.findUnique({
|
||||
where: { id: tourId },
|
||||
select: { title: true }
|
||||
});
|
||||
|
||||
this.commentGateway.notifyJoinRequestAccepted(joinRequest.userId, {
|
||||
tourId,
|
||||
tourTitle: tour?.title || 'Hành trình',
|
||||
requestId
|
||||
});
|
||||
|
||||
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
|
||||
}
|
||||
|
||||
@@ -2319,36 +2381,7 @@ class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
@WebSocketGateway({ cors: { origin: '*' } })
|
||||
export class CommentGateway implements OnGatewayConnection {
|
||||
@WebSocketServer() server: Server;
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
console.log(`[WS] Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinTour')
|
||||
handleJoinTour(client: Socket, tourId: string) {
|
||||
client.join(`tour_${tourId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinPhoto')
|
||||
handleJoinPhoto(client: Socket, photoId: string) {
|
||||
client.join(`photo_${photoId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
|
||||
}
|
||||
|
||||
notifyNewComment(tourId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Tour này
|
||||
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
|
||||
}
|
||||
|
||||
notifyNewPhotoComment(photoId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Ảnh này
|
||||
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('locations')
|
||||
class CommentController {
|
||||
@@ -2896,6 +2929,426 @@ class PublicPhotoController {
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('connections')
|
||||
class ConnectionController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getConnections(@Req() req: any) {
|
||||
const currentUserId = req.user.id;
|
||||
// Get connections where status is ACCEPTED
|
||||
const connections = await this.prisma.userConnection.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: currentUserId },
|
||||
{ receiverId: currentUserId }
|
||||
],
|
||||
status: 'ACCEPTED'
|
||||
},
|
||||
include: {
|
||||
requester: { select: { id: true, name: true, email: true, avatar: true } },
|
||||
receiver: { select: { id: true, name: true, email: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Format connections to return the target user and relation details
|
||||
const formattedConnections = connections.map(conn => {
|
||||
const isRequester = conn.requesterId === currentUserId;
|
||||
const targetUser = isRequester ? conn.receiver : conn.requester;
|
||||
return {
|
||||
id: conn.id,
|
||||
targetUser,
|
||||
type: conn.type, // FRIEND / FAMILY
|
||||
status: conn.status,
|
||||
createdAt: conn.createdAt
|
||||
};
|
||||
});
|
||||
|
||||
// Get pending requests received by current user
|
||||
const receivedRequests = await this.prisma.userConnection.findMany({
|
||||
where: {
|
||||
receiverId: currentUserId,
|
||||
status: 'PENDING'
|
||||
},
|
||||
include: {
|
||||
requester: { select: { id: true, name: true, email: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Get pending requests sent by current user
|
||||
const sentRequests = await this.prisma.userConnection.findMany({
|
||||
where: {
|
||||
requesterId: currentUserId,
|
||||
status: 'PENDING'
|
||||
},
|
||||
include: {
|
||||
receiver: { select: { id: true, name: true, email: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
connections: formattedConnections,
|
||||
receivedRequests: receivedRequests.map(r => ({ id: r.id, requester: r.requester, type: r.type, createdAt: r.createdAt })),
|
||||
sentRequests: sentRequests.map(r => ({ id: r.id, receiver: r.receiver, type: r.type, createdAt: r.createdAt }))
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
async sendRequest(@Req() req: any, @Body() body: { receiverId: string }) {
|
||||
const requesterId = req.user.id;
|
||||
const { receiverId } = body;
|
||||
|
||||
if (requesterId === receiverId) {
|
||||
throw new BadRequestException('Bạn không thể gửi lời mời kết nối cho chính mình.');
|
||||
}
|
||||
|
||||
const receiver = await this.prisma.user.findUnique({ where: { id: receiverId } });
|
||||
if (!receiver) {
|
||||
throw new NotFoundException('Không tìm thấy người nhận.');
|
||||
}
|
||||
|
||||
// Check if relation already exists
|
||||
const existing = await this.prisma.userConnection.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId, receiverId },
|
||||
{ requesterId: receiverId, receiverId }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'ACCEPTED') {
|
||||
throw new BadRequestException('Hai bạn đã kết nối với nhau.');
|
||||
}
|
||||
if (existing.status === 'PENDING') {
|
||||
throw new BadRequestException('Yêu cầu kết nối đang chờ xử lý.');
|
||||
}
|
||||
// If rejected, allow sending request again by deleting/updating the old one
|
||||
await this.prisma.userConnection.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
const newConn = await this.prisma.userConnection.create({
|
||||
data: {
|
||||
requesterId,
|
||||
receiverId,
|
||||
status: 'PENDING',
|
||||
type: 'FRIEND' // default
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, connection: newConn };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: { status?: 'ACCEPTED' | 'REJECTED'; type?: 'FRIEND' | 'FAMILY' },
|
||||
@Req() req: any
|
||||
) {
|
||||
const currentUserId = req.user.id;
|
||||
const conn = await this.prisma.userConnection.findUnique({ where: { id } });
|
||||
if (!conn) {
|
||||
throw new NotFoundException('Không tìm thấy bản ghi kết nối.');
|
||||
}
|
||||
|
||||
// Accept/Reject request
|
||||
if (body.status) {
|
||||
if (conn.receiverId !== currentUserId) {
|
||||
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này.');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.userConnection.update({
|
||||
where: { id },
|
||||
data: { status: body.status }
|
||||
});
|
||||
|
||||
if (body.status === 'ACCEPTED') {
|
||||
const receiverUser = await this.prisma.user.findUnique({
|
||||
where: { id: currentUserId },
|
||||
select: { name: true }
|
||||
});
|
||||
this.commentGateway.notifyConnectionAccepted(conn.requesterId, {
|
||||
connectionId: conn.id,
|
||||
acceptedByName: receiverUser?.name || 'Ai đó',
|
||||
acceptedById: currentUserId
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, connection: updated };
|
||||
}
|
||||
|
||||
// Change relationship type (FRIEND <-> FAMILY)
|
||||
if (body.type) {
|
||||
if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) {
|
||||
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này.');
|
||||
}
|
||||
if (conn.status !== 'ACCEPTED') {
|
||||
throw new BadRequestException('Chỉ có thể thay đổi phân nhóm sau khi đã chấp nhận kết nối.');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.userConnection.update({
|
||||
where: { id },
|
||||
data: { type: body.type }
|
||||
});
|
||||
return { success: true, connection: updated };
|
||||
}
|
||||
|
||||
throw new BadRequestException('Yêu cầu không hợp lệ.');
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteConnection(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
const currentUserId = req.user.id;
|
||||
const conn = await this.prisma.userConnection.findUnique({ where: { id } });
|
||||
if (!conn) {
|
||||
throw new NotFoundException('Không tìm thấy bản ghi kết nối.');
|
||||
}
|
||||
|
||||
if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) {
|
||||
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này.');
|
||||
}
|
||||
|
||||
await this.prisma.userConnection.delete({ where: { id } });
|
||||
return { success: true, message: 'Đã hủy kết nối thành công.' };
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('messages')
|
||||
class DirectMessageController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@Get(':userId')
|
||||
async getMessages(@Param('userId', ParseUUIDPipe) userId: string, @Req() req: any) {
|
||||
const currentUserId = req.user.id;
|
||||
|
||||
// Check connection first to ensure they are connected
|
||||
const conn = await this.prisma.userConnection.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: currentUserId, receiverId: userId },
|
||||
{ requesterId: userId, receiverId: currentUserId }
|
||||
],
|
||||
status: 'ACCEPTED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
throw new ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
||||
}
|
||||
|
||||
const messages = await this.prisma.directMessage.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ senderId: currentUserId, receiverId: userId },
|
||||
{ senderId: userId, receiverId: currentUserId }
|
||||
]
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async sendMessage(
|
||||
@Req() req: any,
|
||||
@Body() body: { receiverId: string; content?: string; attachmentUrl?: string; latitude?: number; longitude?: number }
|
||||
) {
|
||||
const senderId = req.user.id;
|
||||
const { receiverId, content, attachmentUrl, latitude, longitude } = body;
|
||||
|
||||
if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) {
|
||||
throw new BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.');
|
||||
}
|
||||
|
||||
// Check connection first
|
||||
const conn = await this.prisma.userConnection.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ requesterId: senderId, receiverId: receiverId },
|
||||
{ requesterId: receiverId, receiverId: senderId }
|
||||
],
|
||||
status: 'ACCEPTED'
|
||||
}
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
throw new ForbiddenException('Bạn chỉ có thể nhắn tin với người đã kết nối.');
|
||||
}
|
||||
|
||||
const message = await this.prisma.directMessage.create({
|
||||
data: {
|
||||
senderId,
|
||||
receiverId,
|
||||
content: content || '',
|
||||
attachmentUrl,
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Realtime broadcast via Socket
|
||||
this.commentGateway.notifyNewMessage(receiverId, message);
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('upload')
|
||||
class UploadController {
|
||||
@Post()
|
||||
@UseInterceptors(FilesInterceptor('image', 1))
|
||||
async uploadAttachment(@UploadedFiles() files: any[]) {
|
||||
if (!files || files.length === 0) {
|
||||
throw new BadRequestException('Vui lòng chọn ảnh.');
|
||||
}
|
||||
const file = files[0];
|
||||
const attachmentsDir = path.join(UPLOAD_ROOT, 'attachments');
|
||||
if (!fs.existsSync(attachmentsDir)) {
|
||||
fs.mkdirSync(attachmentsDir, { recursive: true });
|
||||
}
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const filename = `${uniqueSuffix}${extension}`;
|
||||
const filePath = path.join(attachmentsDir, filename);
|
||||
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
return {
|
||||
url: `/uploads/attachments/${filename}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('tours/:tourId/messages')
|
||||
class TourMessageController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getTourMessages(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check if user is a participant of this tour
|
||||
const participant = await this.prisma.tourParticipant.findFirst({
|
||||
where: { tourId, userId }
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new ForbiddenException('Bạn không phải là thành viên của hành trình này.');
|
||||
}
|
||||
|
||||
const messages = await this.prisma.tourMessage.findMany({
|
||||
where: { tourId },
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, avatar: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async sendTourMessage(
|
||||
@Param('tourId', ParseUUIDPipe) tourId: string,
|
||||
@Req() req: any,
|
||||
@Body() body: { content?: string; attachmentUrl?: string; latitude?: number; longitude?: number; taggedUserIds?: string[] }
|
||||
) {
|
||||
const senderId = req.user.id;
|
||||
const { content, attachmentUrl, latitude, longitude, taggedUserIds } = body;
|
||||
|
||||
console.log(`[sendTourMessage] senderId=${senderId}, tourId=${tourId}, taggedUserIds=`, taggedUserIds);
|
||||
|
||||
if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) {
|
||||
throw new BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.');
|
||||
}
|
||||
|
||||
// Check if user is a participant of this tour
|
||||
const participant = await this.prisma.tourParticipant.findFirst({
|
||||
where: { tourId, userId: senderId }
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new ForbiddenException('Bạn không phải là thành viên của hành trình này.');
|
||||
}
|
||||
|
||||
const tourMessage = await this.prisma.tourMessage.create({
|
||||
data: {
|
||||
tourId,
|
||||
senderId,
|
||||
content: content || '',
|
||||
attachmentUrl,
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, avatar: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast to the WebSocket room of this tour
|
||||
this.commentGateway.server.to(`tour_${tourId}`).emit('tourMessageReceived', {
|
||||
tourId,
|
||||
message: tourMessage
|
||||
});
|
||||
|
||||
const hasTags = Array.isArray(taggedUserIds) && taggedUserIds.length > 0;
|
||||
|
||||
// Notify other participants (via their user rooms)
|
||||
// If taggedUserIds is provided, we ONLY notify those tagged users.
|
||||
const otherParticipants = await this.prisma.tourParticipant.findMany({
|
||||
where: {
|
||||
tourId,
|
||||
userId: {
|
||||
not: senderId,
|
||||
in: hasTags ? taggedUserIds : undefined
|
||||
},
|
||||
NOT: { userId: null }
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[sendTourMessage] otherParticipants found count: ${otherParticipants.length}`, otherParticipants.map(o => o.userId));
|
||||
|
||||
const tour = await this.prisma.tour.findUnique({
|
||||
where: { id: tourId },
|
||||
select: { title: true }
|
||||
});
|
||||
|
||||
for (const p of otherParticipants) {
|
||||
if (p.userId) {
|
||||
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í]'),
|
||||
isTagged
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tourMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@@ -2919,7 +3372,7 @@ class PublicPhotoController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}) as any,
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController, AdminController, ConnectionController, DirectMessageController, UploadController, TourMessageController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user