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:
2026-06-21 09:46:37 +07:00
parent 392a4d4766
commit 8d79cd76f6
15 changed files with 3932 additions and 234 deletions
+12 -8
View File
@@ -6,6 +6,18 @@ import { ParticipantRole } from '@prisma/client';
import { Reflector } from '@nestjs/core';
import { CanActivate, ExecutionContext } from '@nestjs/common';
import { Cache } from 'cache-manager';
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
handleJoinUser(client: Socket, userId: string): void;
notifyNewMessage(receiverId: string, data: any): void;
notifyConnectionAccepted(requesterId: string, data: any): void;
notifyJoinRequestAccepted(userId: string, data: any): void;
}
export declare const ROLES_KEY = "roles";
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
export declare class TourRoleGuard implements CanActivate {
@@ -21,11 +33,3 @@ export declare class EmailService {
sendOTP(email: string, otp: string): Promise<any>;
sendTourInvitation(email: string, tourTitle: string, inviteLink: string): Promise<any>;
}
export declare class CommentGateway implements OnGatewayConnection {
server: Server;
handleConnection(client: Socket): void;
handleJoinTour(client: Socket, tourId: string): void;
handleJoinPhoto(client: Socket, photoId: string): void;
notifyNewComment(tourId: string, data: any): void;
notifyNewPhotoComment(photoId: string, data: any): void;
}
+484 -43
View File
@@ -48,7 +48,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CommentGateway = exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = void 0;
exports.EmailService = exports.TourRoleGuard = exports.Roles = exports.ROLES_KEY = exports.CommentGateway = void 0;
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const crypto = __importStar(require("crypto"));
@@ -80,6 +80,64 @@ const compress_cache_interceptor_1 = require("./common/compress-cache.intercepto
const gzip = (0, util_1.promisify)(zlib.gzip);
const gunzip = (0, util_1.promisify)(zlib.gunzip);
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
let CommentGateway = class CommentGateway {
handleConnection(client) {
console.log(`[WS] Client connected: ${client.id}`);
}
handleJoinTour(client, tourId) {
client.join(`tour_${tourId}`);
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
}
handleJoinPhoto(client, photoId) {
client.join(`photo_${photoId}`);
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
}
notifyNewComment(tourId, data) {
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
}
notifyNewPhotoComment(photoId, data) {
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
}
handleJoinUser(client, userId) {
client.join(`user_${userId}`);
console.log(`[WS] Client ${client.id} joined user room: user_${userId}`);
}
notifyNewMessage(receiverId, data) {
this.server.to(`user_${receiverId}`).emit('messageReceived', data);
}
notifyConnectionAccepted(requesterId, data) {
this.server.to(`user_${requesterId}`).emit('connectionAccepted', data);
}
notifyJoinRequestAccepted(userId, data) {
this.server.to(`user_${userId}`).emit('joinRequestAccepted', data);
}
};
exports.CommentGateway = CommentGateway;
__decorate([
(0, websockets_1.WebSocketServer)(),
__metadata("design:type", socket_io_1.Server)
], CommentGateway.prototype, "server", void 0);
__decorate([
(0, websockets_1.SubscribeMessage)('joinTour'),
__metadata("design:type", Function),
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
__metadata("design:returntype", void 0)
], CommentGateway.prototype, "handleJoinTour", null);
__decorate([
(0, websockets_1.SubscribeMessage)('joinPhoto'),
__metadata("design:type", Function),
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
__metadata("design:returntype", void 0)
], CommentGateway.prototype, "handleJoinPhoto", null);
__decorate([
(0, websockets_1.SubscribeMessage)('joinUser'),
__metadata("design:type", Function),
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
__metadata("design:returntype", void 0)
], CommentGateway.prototype, "handleJoinUser", null);
exports.CommentGateway = CommentGateway = __decorate([
(0, websockets_1.WebSocketGateway)({ cors: { origin: '*' } })
], CommentGateway);
const CACHE_TTL = {
DEFAULT: 600000,
RESOURCE_TO_TOUR: 3600000,
@@ -454,6 +512,7 @@ let AuthController = class AuthController {
name: user.name,
avatar: user.avatar,
isAdmin: user.isAdmin,
isGoogle: true,
},
};
}
@@ -653,10 +712,11 @@ PublicTourController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], PublicTourController);
let TourController = class TourController {
constructor(prisma, emailService, cacheManager) {
constructor(prisma, emailService, cacheManager, commentGateway) {
this.prisma = prisma;
this.emailService = emailService;
this.cacheManager = cacheManager;
this.commentGateway = commentGateway;
}
async createTour(body, req) {
const { title, description, startDate, endDate, adultCount, childCount, childDiscount, tags, members } = body;
@@ -1213,6 +1273,15 @@ let TourController = class TourController {
data: { status: 'ACCEPTED' },
}),
]);
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.' };
}
async rejectJoinRequest(tourId, requestId, req) {
@@ -1707,7 +1776,7 @@ TourController = __decorate([
(0, common_1.Controller)('tours'),
__param(2, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
EmailService, Object])
EmailService, Object, CommentGateway])
], TourController);
let LocationController = class LocationController {
constructor(prisma, cacheManager) {
@@ -2361,45 +2430,6 @@ UserController = __decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], UserController);
let CommentGateway = class CommentGateway {
handleConnection(client) {
console.log(`[WS] Client connected: ${client.id}`);
}
handleJoinTour(client, tourId) {
client.join(`tour_${tourId}`);
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
}
handleJoinPhoto(client, photoId) {
client.join(`photo_${photoId}`);
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
}
notifyNewComment(tourId, data) {
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
}
notifyNewPhotoComment(photoId, data) {
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
}
};
exports.CommentGateway = CommentGateway;
__decorate([
(0, websockets_1.WebSocketServer)(),
__metadata("design:type", socket_io_1.Server)
], CommentGateway.prototype, "server", void 0);
__decorate([
(0, websockets_1.SubscribeMessage)('joinTour'),
__metadata("design:type", Function),
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
__metadata("design:returntype", void 0)
], CommentGateway.prototype, "handleJoinTour", null);
__decorate([
(0, websockets_1.SubscribeMessage)('joinPhoto'),
__metadata("design:type", Function),
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
__metadata("design:returntype", void 0)
], CommentGateway.prototype, "handleJoinPhoto", null);
exports.CommentGateway = CommentGateway = __decorate([
(0, websockets_1.WebSocketGateway)({ cors: { origin: '*' } })
], CommentGateway);
let CommentController = class CommentController {
constructor(prisma, commentGateway) {
this.prisma = prisma;
@@ -2961,6 +2991,417 @@ PublicPhotoController = __decorate([
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
CommentGateway])
], PublicPhotoController);
let ConnectionController = class ConnectionController {
constructor(prisma, commentGateway) {
this.prisma = prisma;
this.commentGateway = commentGateway;
}
async getConnections(req) {
const currentUserId = req.user.id;
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 } }
}
});
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,
status: conn.status,
createdAt: conn.createdAt
};
});
const receivedRequests = await this.prisma.userConnection.findMany({
where: {
receiverId: currentUserId,
status: 'PENDING'
},
include: {
requester: { select: { id: true, name: true, email: true, avatar: true } }
}
});
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 }))
};
}
async sendRequest(req, body) {
const requesterId = req.user.id;
const { receiverId } = body;
if (requesterId === receiverId) {
throw new common_1.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 common_1.NotFoundException('Không tìm thấy người nhận.');
}
const existing = await this.prisma.userConnection.findFirst({
where: {
OR: [
{ requesterId, receiverId },
{ requesterId: receiverId, receiverId }
]
}
});
if (existing) {
if (existing.status === 'ACCEPTED') {
throw new common_1.BadRequestException('Hai bạn đã kết nối với nhau.');
}
if (existing.status === 'PENDING') {
throw new common_1.BadRequestException('Yêu cầu kết nối đang chờ xử lý.');
}
await this.prisma.userConnection.delete({ where: { id: existing.id } });
}
const newConn = await this.prisma.userConnection.create({
data: {
requesterId,
receiverId,
status: 'PENDING',
type: 'FRIEND'
}
});
return { success: true, connection: newConn };
}
async updateRequest(id, body, req) {
const currentUserId = req.user.id;
const conn = await this.prisma.userConnection.findUnique({ where: { id } });
if (!conn) {
throw new common_1.NotFoundException('Không tìm thấy bản ghi kết nối.');
}
if (body.status) {
if (conn.receiverId !== currentUserId) {
throw new common_1.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 };
}
if (body.type) {
if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) {
throw new common_1.ForbiddenException('Bạn không có quyền thực hiện hành động này.');
}
if (conn.status !== 'ACCEPTED') {
throw new common_1.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 common_1.BadRequestException('Yêu cầu không hợp lệ.');
}
async deleteConnection(id, req) {
const currentUserId = req.user.id;
const conn = await this.prisma.userConnection.findUnique({ where: { id } });
if (!conn) {
throw new common_1.NotFoundException('Không tìm thấy bản ghi kết nối.');
}
if (conn.requesterId !== currentUserId && conn.receiverId !== currentUserId) {
throw new common_1.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.' };
}
};
__decorate([
(0, common_1.Get)(),
__param(0, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object]),
__metadata("design:returntype", Promise)
], ConnectionController.prototype, "getConnections", null);
__decorate([
(0, common_1.Post)(),
__param(0, (0, common_1.Req)()),
__param(1, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object, Object]),
__metadata("design:returntype", Promise)
], ConnectionController.prototype, "sendRequest", null);
__decorate([
(0, common_1.Patch)(':id'),
__param(0, (0, common_1.Param)('id', 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)
], ConnectionController.prototype, "updateRequest", null);
__decorate([
(0, common_1.Delete)(':id'),
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise)
], ConnectionController.prototype, "deleteConnection", null);
ConnectionController = __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Controller)('connections'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
CommentGateway])
], ConnectionController);
let DirectMessageController = class DirectMessageController {
constructor(prisma, commentGateway) {
this.prisma = prisma;
this.commentGateway = commentGateway;
}
async getMessages(userId, req) {
const currentUserId = req.user.id;
const conn = await this.prisma.userConnection.findFirst({
where: {
OR: [
{ requesterId: currentUserId, receiverId: userId },
{ requesterId: userId, receiverId: currentUserId }
],
status: 'ACCEPTED'
}
});
if (!conn) {
throw new common_1.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;
}
async sendMessage(req, body) {
const senderId = req.user.id;
const { receiverId, content, attachmentUrl, latitude, longitude } = body;
if ((!content || !content.trim()) && !attachmentUrl && latitude === undefined) {
throw new common_1.BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.');
}
const conn = await this.prisma.userConnection.findFirst({
where: {
OR: [
{ requesterId: senderId, receiverId: receiverId },
{ requesterId: receiverId, receiverId: senderId }
],
status: 'ACCEPTED'
}
});
if (!conn) {
throw new common_1.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 } }
}
});
this.commentGateway.notifyNewMessage(receiverId, message);
return message;
}
};
__decorate([
(0, common_1.Get)(':userId'),
__param(0, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise)
], DirectMessageController.prototype, "getMessages", null);
__decorate([
(0, common_1.Post)(),
__param(0, (0, common_1.Req)()),
__param(1, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Object, Object]),
__metadata("design:returntype", Promise)
], DirectMessageController.prototype, "sendMessage", null);
DirectMessageController = __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Controller)('messages'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
CommentGateway])
], DirectMessageController);
let UploadController = class UploadController {
async uploadAttachment(files) {
if (!files || files.length === 0) {
throw new common_1.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}`
};
}
};
__decorate([
(0, common_1.Post)(),
(0, common_1.UseInterceptors)((0, platform_express_1.FilesInterceptor)('image', 1)),
__param(0, (0, common_1.UploadedFiles)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [Array]),
__metadata("design:returntype", Promise)
], UploadController.prototype, "uploadAttachment", null);
UploadController = __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Controller)('upload')
], UploadController);
let TourMessageController = class TourMessageController {
constructor(prisma, commentGateway) {
this.prisma = prisma;
this.commentGateway = commentGateway;
}
async getTourMessages(tourId, req) {
const userId = req.user.id;
const participant = await this.prisma.tourParticipant.findFirst({
where: { tourId, userId }
});
if (!participant) {
throw new common_1.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;
}
async sendTourMessage(tourId, req, body) {
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 common_1.BadRequestException('Tin nhắn phải có nội dung, ảnh đính kèm hoặc vị trí.');
}
const participant = await this.prisma.tourParticipant.findFirst({
where: { tourId, userId: senderId }
});
if (!participant) {
throw new common_1.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 } }
}
});
this.commentGateway.server.to(`tour_${tourId}`).emit('tourMessageReceived', {
tourId,
message: tourMessage
});
const hasTags = Array.isArray(taggedUserIds) && taggedUserIds.length > 0;
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;
}
};
__decorate([
(0, common_1.Get)(),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object]),
__metadata("design:returntype", Promise)
], TourMessageController.prototype, "getTourMessages", null);
__decorate([
(0, common_1.Post)(),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Req)()),
__param(2, (0, common_1.Body)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise)
], TourMessageController.prototype, "sendTourMessage", null);
TourMessageController = __decorate([
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
(0, common_1.Controller)('tours/:tourId/messages'),
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
CommentGateway])
], TourMessageController);
let AppModule = class AppModule {
};
AppModule = __decorate([
@@ -2985,7 +3426,7 @@ AppModule = __decorate([
signOptions: { expiresIn: '1d' },
}),
],
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: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService, config_1.ConfigService],
exports: [prisma_service_1.PrismaService]
})
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "ConnectionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ConnectionType" AS ENUM ('FRIEND', 'FAMILY');
-- CreateTable
CREATE TABLE "UserConnection" (
"id" TEXT NOT NULL,
"requesterId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"status" "ConnectionStatus" NOT NULL DEFAULT 'PENDING',
"type" "ConnectionType" NOT NULL DEFAULT 'FRIEND',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DirectMessage" (
"id" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"receiverId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DirectMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "UserConnection_requesterId_idx" ON "UserConnection"("requesterId");
-- CreateIndex
CREATE INDEX "UserConnection_receiverId_idx" ON "UserConnection"("receiverId");
-- CreateIndex
CREATE UNIQUE INDEX "UserConnection_requesterId_receiverId_key" ON "UserConnection"("requesterId", "receiverId");
-- CreateIndex
CREATE INDEX "DirectMessage_senderId_idx" ON "DirectMessage"("senderId");
-- CreateIndex
CREATE INDEX "DirectMessage_receiverId_idx" ON "DirectMessage"("receiverId");
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_requesterId_fkey" FOREIGN KEY ("requesterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserConnection" ADD CONSTRAINT "UserConnection_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DirectMessage" ADD CONSTRAINT "DirectMessage_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- AlterTable
ALTER TABLE "DirectMessage" ADD COLUMN "attachmentUrl" TEXT,
ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;
-- CreateTable
CREATE TABLE "TourMessage" (
"id" TEXT NOT NULL,
"tourId" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"attachmentUrl" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TourMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TourMessage_tourId_idx" ON "TourMessage"("tourId");
-- CreateIndex
CREATE INDEX "TourMessage_senderId_idx" ON "TourMessage"("senderId");
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TourMessage" ADD CONSTRAINT "TourMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+70 -1
View File
@@ -75,7 +75,11 @@ model User {
uploadedPhotos Photo[]
paidExpenses Expense[] @relation("ExpensePaidBy")
comments Comment[]
sentConnections UserConnection[] @relation("ConnectionRequester")
receivedConnections UserConnection[] @relation("ConnectionReceiver")
sentMessages DirectMessage[] @relation("MessageSender")
receivedMessages DirectMessage[] @relation("MessageReceiver")
tourMessages TourMessage[]
}
model Tour {
@@ -100,6 +104,7 @@ model Tour {
legs Leg[]
photos Photo[]
invitations TourInvitation[]
tourMessages TourMessage[]
}
model JoinRequest {
@@ -227,3 +232,67 @@ model TourInvitation {
@@unique([tourId, email])
}
enum ConnectionStatus {
PENDING
ACCEPTED
REJECTED
}
enum ConnectionType {
FRIEND
FAMILY
}
model UserConnection {
id String @id @default(uuid())
requesterId String
receiverId String
status ConnectionStatus @default(PENDING)
type ConnectionType @default(FRIEND)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
requester User @relation("ConnectionRequester", fields: [requesterId], references: [id], onDelete: Cascade)
receiver User @relation("ConnectionReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@unique([requesterId, receiverId])
@@index([requesterId])
@@index([receiverId])
}
model DirectMessage {
id String @id @default(uuid())
senderId String
receiverId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sender User @relation("MessageSender", fields: [senderId], references: [id], onDelete: Cascade)
receiver User @relation("MessageReceiver", fields: [receiverId], references: [id], onDelete: Cascade)
@@index([senderId])
@@index([receiverId])
}
model TourMessage {
id String @id @default(uuid())
tourId String
senderId String
content String @db.Text
attachmentUrl String?
latitude Float?
longitude Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
@@index([tourId])
@@index([senderId])
}
+484 -31
View File
@@ -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]
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB