feat(messaging): add in-app messaging module with Conversation + Message models
Implements buyer-agent in-app messaging (Task 8.4): - Prisma models: Conversation, ConversationParticipant, Message - Full DDD module: domain entities, repository interfaces, CQRS commands/queries - REST API: POST/GET conversations, POST/GET messages, PATCH read, DELETE messages - WebSocket gateway (/messaging namespace): real-time message delivery, typing indicators, room-based routing - 46 unit tests covering handlers, repositories, controller, and gateway Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -13,6 +13,7 @@ import { InquiriesModule } from '@modules/inquiries';
|
|||||||
import { LeadsModule } from '@modules/leads';
|
import { LeadsModule } from '@modules/leads';
|
||||||
import { ListingsModule } from '@modules/listings';
|
import { ListingsModule } from '@modules/listings';
|
||||||
import { McpIntegrationModule } from '@modules/mcp';
|
import { McpIntegrationModule } from '@modules/mcp';
|
||||||
|
import { MessagingModule } from '@modules/messaging';
|
||||||
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||||
import { NotificationsModule } from '@modules/notifications';
|
import { NotificationsModule } from '@modules/notifications';
|
||||||
import { PaymentsModule } from '@modules/payments';
|
import { PaymentsModule } from '@modules/payments';
|
||||||
@@ -46,6 +47,7 @@ import { AppController } from './app.controller';
|
|||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
MetricsModule,
|
MetricsModule,
|
||||||
McpIntegrationModule,
|
McpIntegrationModule,
|
||||||
|
MessagingModule,
|
||||||
|
|
||||||
// ── Rate Limiting ──
|
// ── Rate Limiting ──
|
||||||
// Default: 60 requests per 60 seconds per IP
|
// Default: 60 requests per 60 seconds per IP
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { CreateConversationCommand } from '../commands/create-conversation/create-conversation.command';
|
||||||
|
import { CreateConversationHandler } from '../commands/create-conversation/create-conversation.handler';
|
||||||
|
|
||||||
|
describe('CreateConversationHandler', () => {
|
||||||
|
let handler: CreateConversationHandler;
|
||||||
|
let mockConversationRepo: {
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
findExistingBetweenUsers: ReturnType<typeof vi.fn>;
|
||||||
|
updateLastMessage: ReturnType<typeof vi.fn>;
|
||||||
|
incrementUnreadCount: ReturnType<typeof vi.fn>;
|
||||||
|
findById: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockMessageRepo: {
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const conversationEntity = {
|
||||||
|
id: 'conv-1',
|
||||||
|
listingId: 'listing-1',
|
||||||
|
subject: null,
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
lastMessage: null,
|
||||||
|
lastMessageAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
participants: [
|
||||||
|
{ id: 'p-1', conversationId: 'conv-1', userId: 'user-1', unreadCount: 0, lastReadAt: null, joinedAt: new Date() },
|
||||||
|
{ id: 'p-2', conversationId: 'conv-1', userId: 'user-2', unreadCount: 0, lastReadAt: null, joinedAt: new Date() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageEntity = {
|
||||||
|
id: 'msg-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT' as const,
|
||||||
|
content: 'Hello!',
|
||||||
|
metadata: null,
|
||||||
|
editedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConversationRepo = {
|
||||||
|
create: vi.fn().mockResolvedValue(conversationEntity),
|
||||||
|
findExistingBetweenUsers: vi.fn().mockResolvedValue(null),
|
||||||
|
updateLastMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
incrementUnreadCount: vi.fn().mockResolvedValue(undefined),
|
||||||
|
findById: vi.fn(),
|
||||||
|
};
|
||||||
|
mockMessageRepo = {
|
||||||
|
create: vi.fn().mockResolvedValue(messageEntity),
|
||||||
|
};
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
|
||||||
|
handler = new CreateConversationHandler(
|
||||||
|
mockConversationRepo as any,
|
||||||
|
mockMessageRepo as any,
|
||||||
|
mockEventBus as any,
|
||||||
|
mockLogger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new conversation with participants', async () => {
|
||||||
|
const command = new CreateConversationCommand('user-1', 'user-2', 'listing-1');
|
||||||
|
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockConversationRepo.findExistingBetweenUsers).toHaveBeenCalledWith(
|
||||||
|
['user-1', 'user-2'],
|
||||||
|
'listing-1',
|
||||||
|
);
|
||||||
|
expect(mockConversationRepo.create).toHaveBeenCalledWith({
|
||||||
|
listingId: 'listing-1',
|
||||||
|
subject: undefined,
|
||||||
|
participantUserIds: ['user-1', 'user-2'],
|
||||||
|
});
|
||||||
|
expect(result.id).toBe('conv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates conversation with initial message', async () => {
|
||||||
|
const command = new CreateConversationCommand('user-1', 'user-2', 'listing-1', undefined, 'Hello!');
|
||||||
|
|
||||||
|
await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockMessageRepo.create).toHaveBeenCalledWith({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT',
|
||||||
|
content: 'Hello!',
|
||||||
|
});
|
||||||
|
expect(mockConversationRepo.updateLastMessage).toHaveBeenCalled();
|
||||||
|
expect(mockConversationRepo.incrementUnreadCount).toHaveBeenCalledWith('conv-1', 'user-1');
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns existing conversation when one exists between users', async () => {
|
||||||
|
mockConversationRepo.findExistingBetweenUsers.mockResolvedValue(conversationEntity);
|
||||||
|
|
||||||
|
const command = new CreateConversationCommand('user-1', 'user-2', 'listing-1');
|
||||||
|
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.id).toBe('conv-1');
|
||||||
|
expect(mockConversationRepo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends message in existing conversation when initial message provided', async () => {
|
||||||
|
mockConversationRepo.findExistingBetweenUsers.mockResolvedValue(conversationEntity);
|
||||||
|
|
||||||
|
const command = new CreateConversationCommand('user-1', 'user-2', 'listing-1', undefined, 'Hello again!');
|
||||||
|
|
||||||
|
await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockConversationRepo.create).not.toHaveBeenCalled();
|
||||||
|
expect(mockMessageRepo.create).toHaveBeenCalledWith({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT',
|
||||||
|
content: 'Hello again!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when creating conversation with yourself', async () => {
|
||||||
|
const command = new CreateConversationCommand('user-1', 'user-1');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Không thể tạo hội thoại với chính mình');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { GetConversationsHandler } from '../queries/get-conversations/get-conversations.handler';
|
||||||
|
import { GetConversationsQuery } from '../queries/get-conversations/get-conversations.query';
|
||||||
|
|
||||||
|
describe('GetConversationsHandler', () => {
|
||||||
|
let handler: GetConversationsHandler;
|
||||||
|
let mockConversationRepo: {
|
||||||
|
findByUserId: ReturnType<typeof vi.fn>;
|
||||||
|
countByUserId: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversations = [
|
||||||
|
{
|
||||||
|
id: 'conv-1',
|
||||||
|
listingId: 'listing-1',
|
||||||
|
subject: null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
lastMessage: 'Hello!',
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
participants: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConversationRepo = {
|
||||||
|
findByUserId: vi.fn().mockResolvedValue(conversations),
|
||||||
|
countByUserId: vi.fn().mockResolvedValue(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
handler = new GetConversationsHandler(mockConversationRepo as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns conversations with total count', async () => {
|
||||||
|
const query = new GetConversationsQuery('user-1', 20, 0);
|
||||||
|
|
||||||
|
const result = await handler.execute(query);
|
||||||
|
|
||||||
|
expect(result.conversations).toHaveLength(1);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(mockConversationRepo.findByUserId).toHaveBeenCalledWith('user-1', 20, 0);
|
||||||
|
expect(mockConversationRepo.countByUserId).toHaveBeenCalledWith('user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { GetMessagesHandler } from '../queries/get-messages/get-messages.handler';
|
||||||
|
import { GetMessagesQuery } from '../queries/get-messages/get-messages.query';
|
||||||
|
|
||||||
|
describe('GetMessagesHandler', () => {
|
||||||
|
let handler: GetMessagesHandler;
|
||||||
|
let mockConversationRepo: {
|
||||||
|
findById: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockMessageRepo: {
|
||||||
|
findByConversationId: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversation = {
|
||||||
|
id: 'conv-1',
|
||||||
|
participants: [
|
||||||
|
{ userId: 'user-1' },
|
||||||
|
{ userId: 'user-2' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: 'msg-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT',
|
||||||
|
content: 'Hello!',
|
||||||
|
metadata: null,
|
||||||
|
editedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConversationRepo = {
|
||||||
|
findById: vi.fn().mockResolvedValue(conversation),
|
||||||
|
};
|
||||||
|
mockMessageRepo = {
|
||||||
|
findByConversationId: vi.fn().mockResolvedValue(messages),
|
||||||
|
};
|
||||||
|
|
||||||
|
handler = new GetMessagesHandler(
|
||||||
|
mockConversationRepo as any,
|
||||||
|
mockMessageRepo as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns messages for a conversation', async () => {
|
||||||
|
const query = new GetMessagesQuery('conv-1', 'user-1', 50);
|
||||||
|
|
||||||
|
const result = await handler.execute(query);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(mockMessageRepo.findByConversationId).toHaveBeenCalledWith('conv-1', 50, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when conversation does not exist', async () => {
|
||||||
|
mockConversationRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const query = new GetMessagesQuery('conv-999', 'user-1');
|
||||||
|
|
||||||
|
await expect(handler.execute(query)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ForbiddenException when user is not a participant', async () => {
|
||||||
|
const query = new GetMessagesQuery('conv-1', 'user-3');
|
||||||
|
|
||||||
|
await expect(handler.execute(query)).rejects.toThrow('Bạn không phải là thành viên của hội thoại này');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { MarkConversationReadCommand } from '../commands/mark-read/mark-read.command';
|
||||||
|
import { MarkConversationReadHandler } from '../commands/mark-read/mark-read.handler';
|
||||||
|
|
||||||
|
describe('MarkConversationReadHandler', () => {
|
||||||
|
let handler: MarkConversationReadHandler;
|
||||||
|
let mockConversationRepo: {
|
||||||
|
findById: ReturnType<typeof vi.fn>;
|
||||||
|
resetUnreadCount: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const conversation = {
|
||||||
|
id: 'conv-1',
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
participants: [
|
||||||
|
{ id: 'p-1', conversationId: 'conv-1', userId: 'user-1', unreadCount: 3, lastReadAt: null, joinedAt: new Date() },
|
||||||
|
{ id: 'p-2', conversationId: 'conv-1', userId: 'user-2', unreadCount: 0, lastReadAt: null, joinedAt: new Date() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConversationRepo = {
|
||||||
|
findById: vi.fn().mockResolvedValue(conversation),
|
||||||
|
resetUnreadCount: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
|
||||||
|
handler = new MarkConversationReadHandler(
|
||||||
|
mockConversationRepo as any,
|
||||||
|
mockLogger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks conversation as read for the user', async () => {
|
||||||
|
const command = new MarkConversationReadCommand('conv-1', 'user-1');
|
||||||
|
|
||||||
|
await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockConversationRepo.resetUnreadCount).toHaveBeenCalledWith('conv-1', 'user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when conversation does not exist', async () => {
|
||||||
|
mockConversationRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const command = new MarkConversationReadCommand('conv-999', 'user-1');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ForbiddenException when user is not a participant', async () => {
|
||||||
|
const command = new MarkConversationReadCommand('conv-1', 'user-3');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Bạn không phải là thành viên của hội thoại này');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { SendMessageCommand } from '../commands/send-message/send-message.command';
|
||||||
|
import { SendMessageHandler } from '../commands/send-message/send-message.handler';
|
||||||
|
|
||||||
|
describe('SendMessageHandler', () => {
|
||||||
|
let handler: SendMessageHandler;
|
||||||
|
let mockConversationRepo: {
|
||||||
|
findById: ReturnType<typeof vi.fn>;
|
||||||
|
updateLastMessage: ReturnType<typeof vi.fn>;
|
||||||
|
incrementUnreadCount: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockMessageRepo: {
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const conversation = {
|
||||||
|
id: 'conv-1',
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
participants: [
|
||||||
|
{ id: 'p-1', conversationId: 'conv-1', userId: 'user-1', unreadCount: 0, lastReadAt: null, joinedAt: new Date() },
|
||||||
|
{ id: 'p-2', conversationId: 'conv-1', userId: 'user-2', unreadCount: 0, lastReadAt: null, joinedAt: new Date() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageEntity = {
|
||||||
|
id: 'msg-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT' as const,
|
||||||
|
content: 'Hello!',
|
||||||
|
metadata: null,
|
||||||
|
editedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConversationRepo = {
|
||||||
|
findById: vi.fn().mockResolvedValue(conversation),
|
||||||
|
updateLastMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
incrementUnreadCount: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
mockMessageRepo = {
|
||||||
|
create: vi.fn().mockResolvedValue(messageEntity),
|
||||||
|
};
|
||||||
|
mockEventBus = { publish: vi.fn() };
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
|
||||||
|
handler = new SendMessageHandler(
|
||||||
|
mockConversationRepo as any,
|
||||||
|
mockMessageRepo as any,
|
||||||
|
mockEventBus as any,
|
||||||
|
mockLogger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a message successfully', async () => {
|
||||||
|
const command = new SendMessageCommand('conv-1', 'user-1', 'Hello!');
|
||||||
|
|
||||||
|
const result = await handler.execute(command);
|
||||||
|
|
||||||
|
expect(result.id).toBe('msg-1');
|
||||||
|
expect(mockMessageRepo.create).toHaveBeenCalledWith({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT',
|
||||||
|
content: 'Hello!',
|
||||||
|
metadata: undefined,
|
||||||
|
});
|
||||||
|
expect(mockConversationRepo.updateLastMessage).toHaveBeenCalled();
|
||||||
|
expect(mockConversationRepo.incrementUnreadCount).toHaveBeenCalledWith('conv-1', 'user-1');
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundException when conversation does not exist', async () => {
|
||||||
|
mockConversationRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const command = new SendMessageCommand('conv-999', 'user-1', 'Hello!');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ForbiddenException when user is not a participant', async () => {
|
||||||
|
const command = new SendMessageCommand('conv-1', 'user-3', 'Hello!');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Bạn không phải là thành viên của hội thoại này');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationException when conversation is closed', async () => {
|
||||||
|
mockConversationRepo.findById.mockResolvedValue({ ...conversation, status: 'CLOSED' });
|
||||||
|
|
||||||
|
const command = new SendMessageCommand('conv-1', 'user-1', 'Hello!');
|
||||||
|
|
||||||
|
await expect(handler.execute(command)).rejects.toThrow('Hội thoại đã đóng');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes MessageSentEvent after successful send', async () => {
|
||||||
|
const command = new SendMessageCommand('conv-1', 'user-1', 'Hello!');
|
||||||
|
|
||||||
|
await handler.execute(command);
|
||||||
|
|
||||||
|
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventName: 'message.sent',
|
||||||
|
aggregateId: 'msg-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
content: 'Hello!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export class CreateConversationCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly initiatorUserId: string,
|
||||||
|
public readonly participantUserId: string,
|
||||||
|
public readonly listingId?: string,
|
||||||
|
public readonly subject?: string,
|
||||||
|
public readonly initialMessage?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { DomainException, ValidationException, type EventBusService, type LoggerService } from '@modules/shared';
|
||||||
|
import type { ConversationEntity } from '../../../domain/entities/conversation.entity';
|
||||||
|
import { MessageSentEvent } from '../../../domain/events/message-sent.event';
|
||||||
|
import {
|
||||||
|
CONVERSATION_REPOSITORY,
|
||||||
|
type IConversationRepository,
|
||||||
|
} from '../../../domain/repositories/conversation.repository';
|
||||||
|
import {
|
||||||
|
MESSAGE_REPOSITORY,
|
||||||
|
type IMessageRepository,
|
||||||
|
} from '../../../domain/repositories/message.repository';
|
||||||
|
import { CreateConversationCommand } from './create-conversation.command';
|
||||||
|
|
||||||
|
@CommandHandler(CreateConversationCommand)
|
||||||
|
export class CreateConversationHandler implements ICommandHandler<CreateConversationCommand> {
|
||||||
|
constructor(
|
||||||
|
@Inject(CONVERSATION_REPOSITORY)
|
||||||
|
private readonly conversationRepo: IConversationRepository,
|
||||||
|
@Inject(MESSAGE_REPOSITORY)
|
||||||
|
private readonly messageRepo: IMessageRepository,
|
||||||
|
private readonly eventBus: EventBusService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: CreateConversationCommand): Promise<ConversationEntity> {
|
||||||
|
try {
|
||||||
|
const { initiatorUserId, participantUserId, listingId, subject, initialMessage } = command;
|
||||||
|
|
||||||
|
if (initiatorUserId === participantUserId) {
|
||||||
|
throw new ValidationException('Không thể tạo hội thoại với chính mình');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing conversation between these users for the same listing
|
||||||
|
const userIds = [initiatorUserId, participantUserId];
|
||||||
|
const existing = await this.conversationRepo.findExistingBetweenUsers(userIds, listingId);
|
||||||
|
if (existing) {
|
||||||
|
// If there's an initial message, send it in the existing conversation
|
||||||
|
if (initialMessage) {
|
||||||
|
const message = await this.messageRepo.create({
|
||||||
|
conversationId: existing.id,
|
||||||
|
senderId: initiatorUserId,
|
||||||
|
type: 'TEXT',
|
||||||
|
content: initialMessage,
|
||||||
|
});
|
||||||
|
await this.conversationRepo.updateLastMessage(existing.id, initialMessage, message.createdAt);
|
||||||
|
await this.conversationRepo.incrementUnreadCount(existing.id, initiatorUserId);
|
||||||
|
this.eventBus.publish(
|
||||||
|
new MessageSentEvent(message.id, existing.id, initiatorUserId, 'TEXT', initialMessage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new conversation
|
||||||
|
const conversation = await this.conversationRepo.create({
|
||||||
|
listingId,
|
||||||
|
subject,
|
||||||
|
participantUserIds: userIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial message if provided
|
||||||
|
if (initialMessage) {
|
||||||
|
const message = await this.messageRepo.create({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
senderId: initiatorUserId,
|
||||||
|
type: 'TEXT',
|
||||||
|
content: initialMessage,
|
||||||
|
});
|
||||||
|
await this.conversationRepo.updateLastMessage(conversation.id, initialMessage, message.createdAt);
|
||||||
|
await this.conversationRepo.incrementUnreadCount(conversation.id, initiatorUserId);
|
||||||
|
this.eventBus.publish(
|
||||||
|
new MessageSentEvent(message.id, conversation.id, initiatorUserId, 'TEXT', initialMessage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Conversation created: ${conversation.id} between [${userIds.join(', ')}]`,
|
||||||
|
'CreateConversationHandler',
|
||||||
|
);
|
||||||
|
|
||||||
|
return conversation;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create conversation: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Không thể tạo hội thoại');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class MarkConversationReadCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly conversationId: string,
|
||||||
|
public readonly userId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { DomainException, ForbiddenException, NotFoundException, type LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
CONVERSATION_REPOSITORY,
|
||||||
|
type IConversationRepository,
|
||||||
|
} from '../../../domain/repositories/conversation.repository';
|
||||||
|
import { MarkConversationReadCommand } from './mark-read.command';
|
||||||
|
|
||||||
|
@CommandHandler(MarkConversationReadCommand)
|
||||||
|
export class MarkConversationReadHandler implements ICommandHandler<MarkConversationReadCommand> {
|
||||||
|
constructor(
|
||||||
|
@Inject(CONVERSATION_REPOSITORY)
|
||||||
|
private readonly conversationRepo: IConversationRepository,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: MarkConversationReadCommand): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { conversationId, userId } = command;
|
||||||
|
|
||||||
|
const conversation = await this.conversationRepo.findById(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
throw new NotFoundException('Conversation', conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isParticipant = conversation.participants.some((p) => p.userId === userId);
|
||||||
|
if (!isParticipant) {
|
||||||
|
throw new ForbiddenException('Bạn không phải là thành viên của hội thoại này');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.conversationRepo.resetUnreadCount(conversationId, userId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to mark conversation read: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Không thể đánh dấu đã đọc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { MessageType } from '../../../domain/value-objects/message-type.vo';
|
||||||
|
|
||||||
|
export class SendMessageCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly conversationId: string,
|
||||||
|
public readonly senderId: string,
|
||||||
|
public readonly content: string,
|
||||||
|
public readonly type: MessageType = 'TEXT',
|
||||||
|
public readonly metadata?: Record<string, unknown>,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { DomainException, ForbiddenException, NotFoundException, ValidationException, type EventBusService, type LoggerService } from '@modules/shared';
|
||||||
|
import type { MessageEntity } from '../../../domain/entities/message.entity';
|
||||||
|
import { MessageSentEvent } from '../../../domain/events/message-sent.event';
|
||||||
|
import {
|
||||||
|
CONVERSATION_REPOSITORY,
|
||||||
|
type IConversationRepository,
|
||||||
|
} from '../../../domain/repositories/conversation.repository';
|
||||||
|
import {
|
||||||
|
MESSAGE_REPOSITORY,
|
||||||
|
type IMessageRepository,
|
||||||
|
} from '../../../domain/repositories/message.repository';
|
||||||
|
import { SendMessageCommand } from './send-message.command';
|
||||||
|
|
||||||
|
@CommandHandler(SendMessageCommand)
|
||||||
|
export class SendMessageHandler implements ICommandHandler<SendMessageCommand> {
|
||||||
|
constructor(
|
||||||
|
@Inject(CONVERSATION_REPOSITORY)
|
||||||
|
private readonly conversationRepo: IConversationRepository,
|
||||||
|
@Inject(MESSAGE_REPOSITORY)
|
||||||
|
private readonly messageRepo: IMessageRepository,
|
||||||
|
private readonly eventBus: EventBusService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: SendMessageCommand): Promise<MessageEntity> {
|
||||||
|
try {
|
||||||
|
const { conversationId, senderId, content, type, metadata } = command;
|
||||||
|
|
||||||
|
// Verify conversation exists and sender is a participant
|
||||||
|
const conversation = await this.conversationRepo.findById(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
throw new NotFoundException('Conversation', conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isParticipant = conversation.participants.some((p) => p.userId === senderId);
|
||||||
|
if (!isParticipant) {
|
||||||
|
throw new ForbiddenException('Bạn không phải là thành viên của hội thoại này');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversation.status !== 'ACTIVE') {
|
||||||
|
throw new ValidationException('Hội thoại đã đóng');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create message
|
||||||
|
const message = await this.messageRepo.create({
|
||||||
|
conversationId,
|
||||||
|
senderId,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update conversation last message
|
||||||
|
await this.conversationRepo.updateLastMessage(conversationId, content, message.createdAt);
|
||||||
|
|
||||||
|
// Increment unread count for other participants
|
||||||
|
await this.conversationRepo.incrementUnreadCount(conversationId, senderId);
|
||||||
|
|
||||||
|
// Publish domain event
|
||||||
|
this.eventBus.publish(
|
||||||
|
new MessageSentEvent(message.id, conversationId, senderId, type, content),
|
||||||
|
);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to send message: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Không thể gửi tin nhắn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import type { ConversationEntity } from '../../../domain/entities/conversation.entity';
|
||||||
|
import {
|
||||||
|
CONVERSATION_REPOSITORY,
|
||||||
|
type IConversationRepository,
|
||||||
|
} from '../../../domain/repositories/conversation.repository';
|
||||||
|
import { GetConversationsQuery } from './get-conversations.query';
|
||||||
|
|
||||||
|
@QueryHandler(GetConversationsQuery)
|
||||||
|
export class GetConversationsHandler implements IQueryHandler<GetConversationsQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(CONVERSATION_REPOSITORY)
|
||||||
|
private readonly conversationRepo: IConversationRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetConversationsQuery): Promise<{ conversations: ConversationEntity[]; total: number }> {
|
||||||
|
const [conversations, total] = await Promise.all([
|
||||||
|
this.conversationRepo.findByUserId(query.userId, query.limit, query.offset),
|
||||||
|
this.conversationRepo.countByUserId(query.userId),
|
||||||
|
]);
|
||||||
|
return { conversations, total };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class GetConversationsQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly limit: number = 20,
|
||||||
|
public readonly offset: number = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { ForbiddenException, NotFoundException } from '@modules/shared';
|
||||||
|
import type { MessageEntity } from '../../../domain/entities/message.entity';
|
||||||
|
import {
|
||||||
|
CONVERSATION_REPOSITORY,
|
||||||
|
type IConversationRepository,
|
||||||
|
} from '../../../domain/repositories/conversation.repository';
|
||||||
|
import {
|
||||||
|
MESSAGE_REPOSITORY,
|
||||||
|
type IMessageRepository,
|
||||||
|
} from '../../../domain/repositories/message.repository';
|
||||||
|
import { GetMessagesQuery } from './get-messages.query';
|
||||||
|
|
||||||
|
@QueryHandler(GetMessagesQuery)
|
||||||
|
export class GetMessagesHandler implements IQueryHandler<GetMessagesQuery> {
|
||||||
|
constructor(
|
||||||
|
@Inject(CONVERSATION_REPOSITORY)
|
||||||
|
private readonly conversationRepo: IConversationRepository,
|
||||||
|
@Inject(MESSAGE_REPOSITORY)
|
||||||
|
private readonly messageRepo: IMessageRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetMessagesQuery): Promise<MessageEntity[]> {
|
||||||
|
// Verify access
|
||||||
|
const conversation = await this.conversationRepo.findById(query.conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
throw new NotFoundException('Conversation', query.conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isParticipant = conversation.participants.some((p) => p.userId === query.userId);
|
||||||
|
if (!isParticipant) {
|
||||||
|
throw new ForbiddenException('Bạn không phải là thành viên của hội thoại này');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.messageRepo.findByConversationId(query.conversationId, query.limit, query.before);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export class GetMessagesQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly conversationId: string,
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly limit: number = 50,
|
||||||
|
public readonly before?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { MessageSentEvent } from '../events/message-sent.event';
|
||||||
|
|
||||||
|
describe('Messaging Domain', () => {
|
||||||
|
describe('MessageSentEvent', () => {
|
||||||
|
it('creates event with correct eventName', () => {
|
||||||
|
const event = new MessageSentEvent('msg-1', 'conv-1', 'user-1', 'TEXT', 'Hello!');
|
||||||
|
|
||||||
|
expect(event.eventName).toBe('message.sent');
|
||||||
|
expect(event.aggregateId).toBe('msg-1');
|
||||||
|
expect(event.messageId).toBe('msg-1');
|
||||||
|
expect(event.conversationId).toBe('conv-1');
|
||||||
|
expect(event.senderId).toBe('user-1');
|
||||||
|
expect(event.type).toBe('TEXT');
|
||||||
|
expect(event.content).toBe('Hello!');
|
||||||
|
expect(event.occurredAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ConversationStatus } from '../value-objects/conversation-status.vo';
|
||||||
|
|
||||||
|
export interface ConversationParticipantEntity {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
userId: string;
|
||||||
|
unreadCount: number;
|
||||||
|
lastReadAt: Date | null;
|
||||||
|
joinedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationEntity {
|
||||||
|
id: string;
|
||||||
|
listingId: string | null;
|
||||||
|
subject: string | null;
|
||||||
|
status: ConversationStatus;
|
||||||
|
lastMessage: string | null;
|
||||||
|
lastMessageAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
participants: ConversationParticipantEntity[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { MessageType } from '../value-objects/message-type.vo';
|
||||||
|
|
||||||
|
export interface MessageEntity {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
type: MessageType;
|
||||||
|
content: string;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
editedAt: Date | null;
|
||||||
|
deletedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { type DomainEvent } from '@modules/shared';
|
||||||
|
import type { MessageType } from '../value-objects/message-type.vo';
|
||||||
|
|
||||||
|
export class MessageSentEvent implements DomainEvent {
|
||||||
|
readonly eventName = 'message.sent';
|
||||||
|
readonly occurredAt = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly aggregateId: string,
|
||||||
|
public readonly conversationId: string,
|
||||||
|
public readonly senderId: string,
|
||||||
|
public readonly type: MessageType,
|
||||||
|
public readonly content: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get messageId(): string {
|
||||||
|
return this.aggregateId;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/modules/messaging/domain/index.ts
Normal file
15
apps/api/src/modules/messaging/domain/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export type { ConversationEntity, ConversationParticipantEntity } from './entities/conversation.entity';
|
||||||
|
export type { MessageEntity } from './entities/message.entity';
|
||||||
|
export { MessageSentEvent } from './events/message-sent.event';
|
||||||
|
export {
|
||||||
|
CONVERSATION_REPOSITORY,
|
||||||
|
type IConversationRepository,
|
||||||
|
type CreateConversationDto,
|
||||||
|
} from './repositories/conversation.repository';
|
||||||
|
export {
|
||||||
|
MESSAGE_REPOSITORY,
|
||||||
|
type IMessageRepository,
|
||||||
|
type CreateMessageDto,
|
||||||
|
} from './repositories/message.repository';
|
||||||
|
export type { ConversationStatus } from './value-objects/conversation-status.vo';
|
||||||
|
export { type MessageType, ALL_MESSAGE_TYPES } from './value-objects/message-type.vo';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ConversationEntity } from '../entities/conversation.entity';
|
||||||
|
import type { ConversationStatus } from '../value-objects/conversation-status.vo';
|
||||||
|
|
||||||
|
export const CONVERSATION_REPOSITORY = Symbol('CONVERSATION_REPOSITORY');
|
||||||
|
|
||||||
|
export interface CreateConversationDto {
|
||||||
|
listingId?: string;
|
||||||
|
subject?: string;
|
||||||
|
participantUserIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConversationRepository {
|
||||||
|
create(dto: CreateConversationDto): Promise<ConversationEntity>;
|
||||||
|
findById(id: string): Promise<ConversationEntity | null>;
|
||||||
|
findByUserId(userId: string, limit?: number, offset?: number): Promise<ConversationEntity[]>;
|
||||||
|
findExistingBetweenUsers(userIds: string[], listingId?: string): Promise<ConversationEntity | null>;
|
||||||
|
updateStatus(id: string, status: ConversationStatus): Promise<void>;
|
||||||
|
updateLastMessage(id: string, content: string, sentAt: Date): Promise<void>;
|
||||||
|
incrementUnreadCount(conversationId: string, excludeUserId: string): Promise<void>;
|
||||||
|
resetUnreadCount(conversationId: string, userId: string): Promise<void>;
|
||||||
|
countByUserId(userId: string): Promise<number>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { MessageEntity } from '../entities/message.entity';
|
||||||
|
import type { MessageType } from '../value-objects/message-type.vo';
|
||||||
|
|
||||||
|
export const MESSAGE_REPOSITORY = Symbol('MESSAGE_REPOSITORY');
|
||||||
|
|
||||||
|
export interface CreateMessageDto {
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
type: MessageType;
|
||||||
|
content: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMessageRepository {
|
||||||
|
create(dto: CreateMessageDto): Promise<MessageEntity>;
|
||||||
|
findByConversationId(conversationId: string, limit?: number, before?: string): Promise<MessageEntity[]>;
|
||||||
|
findById(id: string): Promise<MessageEntity | null>;
|
||||||
|
softDelete(id: string, senderId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type ConversationStatus = 'ACTIVE' | 'ARCHIVED' | 'CLOSED';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type MessageType = 'TEXT' | 'IMAGE' | 'FILE' | 'SYSTEM';
|
||||||
|
|
||||||
|
export const ALL_MESSAGE_TYPES: MessageType[] = ['TEXT', 'IMAGE', 'FILE', 'SYSTEM'];
|
||||||
4
apps/api/src/modules/messaging/index.ts
Normal file
4
apps/api/src/modules/messaging/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { MessagingModule } from './messaging.module';
|
||||||
|
export { MessagingGateway } from './presentation/gateways/messaging.gateway';
|
||||||
|
export { CreateConversationCommand } from './application/commands/create-conversation/create-conversation.command';
|
||||||
|
export { SendMessageCommand } from './application/commands/send-message/send-message.command';
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { PrismaConversationRepository } from '../repositories/prisma-conversation.repository';
|
||||||
|
|
||||||
|
describe('PrismaConversationRepository', () => {
|
||||||
|
let repository: PrismaConversationRepository;
|
||||||
|
let mockPrisma: {
|
||||||
|
conversation: {
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
findUnique: ReturnType<typeof vi.fn>;
|
||||||
|
findMany: ReturnType<typeof vi.fn>;
|
||||||
|
findFirst: ReturnType<typeof vi.fn>;
|
||||||
|
update: ReturnType<typeof vi.fn>;
|
||||||
|
count: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
conversationParticipant: {
|
||||||
|
updateMany: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConversation = {
|
||||||
|
id: 'conv-1',
|
||||||
|
listingId: 'listing-1',
|
||||||
|
subject: null,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
lastMessage: null,
|
||||||
|
lastMessageAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
participants: [
|
||||||
|
{ id: 'p-1', conversationId: 'conv-1', userId: 'user-1', unreadCount: 0, lastReadAt: null, joinedAt: new Date() },
|
||||||
|
{ id: 'p-2', conversationId: 'conv-1', userId: 'user-2', unreadCount: 0, lastReadAt: null, joinedAt: new Date() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrisma = {
|
||||||
|
conversation: {
|
||||||
|
create: vi.fn().mockResolvedValue(mockConversation),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(mockConversation),
|
||||||
|
findMany: vi.fn().mockResolvedValue([mockConversation]),
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
update: vi.fn().mockResolvedValue(mockConversation),
|
||||||
|
count: vi.fn().mockResolvedValue(1),
|
||||||
|
},
|
||||||
|
conversationParticipant: {
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
repository = new PrismaConversationRepository(mockPrisma as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a conversation with participants', async () => {
|
||||||
|
const result = await repository.create({
|
||||||
|
listingId: 'listing-1',
|
||||||
|
participantUserIds: ['user-1', 'user-2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPrisma.conversation.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
listingId: 'listing-1',
|
||||||
|
subject: null,
|
||||||
|
participants: {
|
||||||
|
create: [{ userId: 'user-1' }, { userId: 'user-2' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { participants: true },
|
||||||
|
});
|
||||||
|
expect(result.id).toBe('conv-1');
|
||||||
|
expect(result.participants).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds conversation by id', async () => {
|
||||||
|
const result = await repository.findById('conv-1');
|
||||||
|
|
||||||
|
expect(mockPrisma.conversation.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'conv-1' },
|
||||||
|
include: { participants: true },
|
||||||
|
});
|
||||||
|
expect(result?.id).toBe('conv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when conversation not found', async () => {
|
||||||
|
mockPrisma.conversation.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await repository.findById('conv-999');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds conversations by user id', async () => {
|
||||||
|
const result = await repository.findByUserId('user-1', 20, 0);
|
||||||
|
|
||||||
|
expect(mockPrisma.conversation.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { participants: { some: { userId: 'user-1' } } },
|
||||||
|
take: 20,
|
||||||
|
skip: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates conversation status', async () => {
|
||||||
|
await repository.updateStatus('conv-1', 'CLOSED');
|
||||||
|
|
||||||
|
expect(mockPrisma.conversation.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'conv-1' },
|
||||||
|
data: { status: 'CLOSED' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments unread count for other participants', async () => {
|
||||||
|
await repository.incrementUnreadCount('conv-1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPrisma.conversationParticipant.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { conversationId: 'conv-1', userId: { not: 'user-1' } },
|
||||||
|
data: { unreadCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets unread count for a user', async () => {
|
||||||
|
await repository.resetUnreadCount('conv-1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPrisma.conversationParticipant.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { conversationId: 'conv-1', userId: 'user-1' },
|
||||||
|
data: { unreadCount: 0, lastReadAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts conversations by user id', async () => {
|
||||||
|
const count = await repository.countByUserId('user-1');
|
||||||
|
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { PrismaMessageRepository } from '../repositories/prisma-message.repository';
|
||||||
|
|
||||||
|
describe('PrismaMessageRepository', () => {
|
||||||
|
let repository: PrismaMessageRepository;
|
||||||
|
let mockPrisma: {
|
||||||
|
message: {
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
findMany: ReturnType<typeof vi.fn>;
|
||||||
|
findUnique: ReturnType<typeof vi.fn>;
|
||||||
|
updateMany: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMessage = {
|
||||||
|
id: 'msg-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT',
|
||||||
|
content: 'Hello!',
|
||||||
|
metadata: null,
|
||||||
|
editedAt: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrisma = {
|
||||||
|
message: {
|
||||||
|
create: vi.fn().mockResolvedValue(mockMessage),
|
||||||
|
findMany: vi.fn().mockResolvedValue([mockMessage]),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(mockMessage),
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
repository = new PrismaMessageRepository(mockPrisma as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a message', async () => {
|
||||||
|
const result = await repository.create({
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT',
|
||||||
|
content: 'Hello!',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPrisma.message.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT',
|
||||||
|
content: 'Hello!',
|
||||||
|
metadata: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.id).toBe('msg-1');
|
||||||
|
expect(result.type).toBe('TEXT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds messages by conversation id', async () => {
|
||||||
|
const result = await repository.findByConversationId('conv-1', 50);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.content).toBe('Hello!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds message by id', async () => {
|
||||||
|
const result = await repository.findById('msg-1');
|
||||||
|
|
||||||
|
expect(result?.id).toBe('msg-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when message not found', async () => {
|
||||||
|
mockPrisma.message.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await repository.findById('msg-999');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft-deletes a message by sender only', async () => {
|
||||||
|
await repository.softDelete('msg-1', 'user-1');
|
||||||
|
|
||||||
|
expect(mockPrisma.message.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'msg-1', senderId: 'user-1' },
|
||||||
|
data: { deletedAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
2
apps/api/src/modules/messaging/infrastructure/index.ts
Normal file
2
apps/api/src/modules/messaging/infrastructure/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { PrismaConversationRepository } from './repositories/prisma-conversation.repository';
|
||||||
|
export { PrismaMessageRepository } from './repositories/prisma-message.repository';
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { type PrismaService } from '@modules/shared';
|
||||||
|
import type { ConversationEntity, ConversationParticipantEntity } from '../../domain/entities/conversation.entity';
|
||||||
|
import type {
|
||||||
|
IConversationRepository,
|
||||||
|
CreateConversationDto,
|
||||||
|
} from '../../domain/repositories/conversation.repository';
|
||||||
|
import type { ConversationStatus } from '../../domain/value-objects/conversation-status.vo';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaConversationRepository implements IConversationRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async create(dto: CreateConversationDto): Promise<ConversationEntity> {
|
||||||
|
const record = await this.prisma.conversation.create({
|
||||||
|
data: {
|
||||||
|
listingId: dto.listingId ?? null,
|
||||||
|
subject: dto.subject ?? null,
|
||||||
|
participants: {
|
||||||
|
create: dto.participantUserIds.map((userId) => ({ userId })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { participants: true },
|
||||||
|
});
|
||||||
|
return this.toEntity(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<ConversationEntity | null> {
|
||||||
|
const record = await this.prisma.conversation.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { participants: true },
|
||||||
|
});
|
||||||
|
return record ? this.toEntity(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: string, limit = 20, offset = 0): Promise<ConversationEntity[]> {
|
||||||
|
const records = await this.prisma.conversation.findMany({
|
||||||
|
where: {
|
||||||
|
participants: { some: { userId } },
|
||||||
|
},
|
||||||
|
include: { participants: true },
|
||||||
|
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
return records.map((r) => this.toEntity(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findExistingBetweenUsers(
|
||||||
|
userIds: string[],
|
||||||
|
listingId?: string,
|
||||||
|
): Promise<ConversationEntity | null> {
|
||||||
|
const record = await this.prisma.conversation.findFirst({
|
||||||
|
where: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
...(listingId ? { listingId } : {}),
|
||||||
|
AND: userIds.map((userId) => ({
|
||||||
|
participants: { some: { userId } },
|
||||||
|
})),
|
||||||
|
participants: { every: { userId: { in: userIds } } },
|
||||||
|
},
|
||||||
|
include: { participants: true },
|
||||||
|
});
|
||||||
|
return record ? this.toEntity(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, status: ConversationStatus): Promise<void> {
|
||||||
|
await this.prisma.conversation.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLastMessage(id: string, content: string, sentAt: Date): Promise<void> {
|
||||||
|
await this.prisma.conversation.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
lastMessage: content.length > 200 ? content.slice(0, 200) + '...' : content,
|
||||||
|
lastMessageAt: sentAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementUnreadCount(conversationId: string, excludeUserId: string): Promise<void> {
|
||||||
|
await this.prisma.conversationParticipant.updateMany({
|
||||||
|
where: { conversationId, userId: { not: excludeUserId } },
|
||||||
|
data: { unreadCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetUnreadCount(conversationId: string, userId: string): Promise<void> {
|
||||||
|
await this.prisma.conversationParticipant.updateMany({
|
||||||
|
where: { conversationId, userId },
|
||||||
|
data: { unreadCount: 0, lastReadAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async countByUserId(userId: string): Promise<number> {
|
||||||
|
return this.prisma.conversation.count({
|
||||||
|
where: { participants: { some: { userId } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toEntity(record: {
|
||||||
|
id: string;
|
||||||
|
listingId: string | null;
|
||||||
|
subject: string | null;
|
||||||
|
status: string;
|
||||||
|
lastMessage: string | null;
|
||||||
|
lastMessageAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
participants: Array<{
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
userId: string;
|
||||||
|
unreadCount: number;
|
||||||
|
lastReadAt: Date | null;
|
||||||
|
joinedAt: Date;
|
||||||
|
}>;
|
||||||
|
}): ConversationEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
listingId: record.listingId,
|
||||||
|
subject: record.subject,
|
||||||
|
status: record.status as ConversationStatus,
|
||||||
|
lastMessage: record.lastMessage,
|
||||||
|
lastMessageAt: record.lastMessageAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
participants: record.participants.map((p): ConversationParticipantEntity => ({
|
||||||
|
id: p.id,
|
||||||
|
conversationId: p.conversationId,
|
||||||
|
userId: p.userId,
|
||||||
|
unreadCount: p.unreadCount,
|
||||||
|
lastReadAt: p.lastReadAt,
|
||||||
|
joinedAt: p.joinedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { type Prisma } from '@prisma/client';
|
||||||
|
import { type PrismaService } from '@modules/shared';
|
||||||
|
import type { MessageEntity } from '../../domain/entities/message.entity';
|
||||||
|
import type {
|
||||||
|
IMessageRepository,
|
||||||
|
CreateMessageDto,
|
||||||
|
} from '../../domain/repositories/message.repository';
|
||||||
|
import type { MessageType } from '../../domain/value-objects/message-type.vo';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaMessageRepository implements IMessageRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async create(dto: CreateMessageDto): Promise<MessageEntity> {
|
||||||
|
const record = await this.prisma.message.create({
|
||||||
|
data: {
|
||||||
|
conversationId: dto.conversationId,
|
||||||
|
senderId: dto.senderId,
|
||||||
|
type: dto.type,
|
||||||
|
content: dto.content,
|
||||||
|
metadata: (dto.metadata ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.toEntity(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByConversationId(
|
||||||
|
conversationId: string,
|
||||||
|
limit = 50,
|
||||||
|
before?: string,
|
||||||
|
): Promise<MessageEntity[]> {
|
||||||
|
const records = await this.prisma.message.findMany({
|
||||||
|
where: {
|
||||||
|
conversationId,
|
||||||
|
deletedAt: null,
|
||||||
|
...(before ? { createdAt: { lt: (await this.prisma.message.findUnique({ where: { id: before } }))?.createdAt } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return records.map((r) => this.toEntity(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<MessageEntity | null> {
|
||||||
|
const record = await this.prisma.message.findUnique({ where: { id } });
|
||||||
|
return record ? this.toEntity(record) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(id: string, senderId: string): Promise<void> {
|
||||||
|
await this.prisma.message.updateMany({
|
||||||
|
where: { id, senderId },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toEntity(record: {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
metadata: unknown;
|
||||||
|
editedAt: Date | null;
|
||||||
|
deletedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): MessageEntity {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
conversationId: record.conversationId,
|
||||||
|
senderId: record.senderId,
|
||||||
|
type: record.type as MessageType,
|
||||||
|
content: record.content,
|
||||||
|
metadata: record.metadata as Record<string, unknown> | null,
|
||||||
|
editedAt: record.editedAt,
|
||||||
|
deletedAt: record.deletedAt,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/modules/messaging/messaging.module.ts
Normal file
44
apps/api/src/modules/messaging/messaging.module.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
|
import { AuthModule } from '@modules/auth';
|
||||||
|
import { CreateConversationHandler } from './application/commands/create-conversation/create-conversation.handler';
|
||||||
|
import { MarkConversationReadHandler } from './application/commands/mark-read/mark-read.handler';
|
||||||
|
import { SendMessageHandler } from './application/commands/send-message/send-message.handler';
|
||||||
|
import { GetConversationsHandler } from './application/queries/get-conversations/get-conversations.handler';
|
||||||
|
import { GetMessagesHandler } from './application/queries/get-messages/get-messages.handler';
|
||||||
|
import { CONVERSATION_REPOSITORY } from './domain/repositories/conversation.repository';
|
||||||
|
import { MESSAGE_REPOSITORY } from './domain/repositories/message.repository';
|
||||||
|
import { PrismaConversationRepository } from './infrastructure/repositories/prisma-conversation.repository';
|
||||||
|
import { PrismaMessageRepository } from './infrastructure/repositories/prisma-message.repository';
|
||||||
|
import { MessagingController } from './presentation/controllers/messaging.controller';
|
||||||
|
import { MessagingGateway } from './presentation/gateways/messaging.gateway';
|
||||||
|
|
||||||
|
const CommandHandlers = [
|
||||||
|
CreateConversationHandler,
|
||||||
|
SendMessageHandler,
|
||||||
|
MarkConversationReadHandler,
|
||||||
|
];
|
||||||
|
|
||||||
|
const QueryHandlers = [
|
||||||
|
GetConversationsHandler,
|
||||||
|
GetMessagesHandler,
|
||||||
|
];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CqrsModule, AuthModule],
|
||||||
|
controllers: [MessagingController],
|
||||||
|
providers: [
|
||||||
|
// Repositories
|
||||||
|
{ provide: CONVERSATION_REPOSITORY, useClass: PrismaConversationRepository },
|
||||||
|
{ provide: MESSAGE_REPOSITORY, useClass: PrismaMessageRepository },
|
||||||
|
|
||||||
|
// WebSocket Gateway
|
||||||
|
MessagingGateway,
|
||||||
|
|
||||||
|
// CQRS
|
||||||
|
...CommandHandlers,
|
||||||
|
...QueryHandlers,
|
||||||
|
],
|
||||||
|
exports: [MessagingGateway],
|
||||||
|
})
|
||||||
|
export class MessagingModule {}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { CreateConversationCommand } from '../../application/commands/create-conversation/create-conversation.command';
|
||||||
|
import { MarkConversationReadCommand } from '../../application/commands/mark-read/mark-read.command';
|
||||||
|
import { SendMessageCommand } from '../../application/commands/send-message/send-message.command';
|
||||||
|
import { GetConversationsQuery } from '../../application/queries/get-conversations/get-conversations.query';
|
||||||
|
import { GetMessagesQuery } from '../../application/queries/get-messages/get-messages.query';
|
||||||
|
import { MessagingController } from '../controllers/messaging.controller';
|
||||||
|
|
||||||
|
describe('MessagingController', () => {
|
||||||
|
let controller: MessagingController;
|
||||||
|
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||||
|
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||||
|
let mockMessageRepo: { softDelete: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const user = { sub: 'user-1', role: 'BUYER' };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCommandBus = { execute: vi.fn() };
|
||||||
|
mockQueryBus = { execute: vi.fn() };
|
||||||
|
mockMessageRepo = { softDelete: vi.fn() };
|
||||||
|
|
||||||
|
controller = new MessagingController(
|
||||||
|
mockCommandBus as any,
|
||||||
|
mockQueryBus as any,
|
||||||
|
mockMessageRepo as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createConversation', () => {
|
||||||
|
it('dispatches CreateConversationCommand', async () => {
|
||||||
|
const dto = { participantUserId: 'user-2', listingId: 'listing-1', initialMessage: 'Hi' };
|
||||||
|
mockCommandBus.execute.mockResolvedValue({ id: 'conv-1' });
|
||||||
|
|
||||||
|
const result = await controller.createConversation(user as any, dto as any);
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
||||||
|
expect.any(CreateConversationCommand),
|
||||||
|
);
|
||||||
|
expect(result.id).toBe('conv-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConversations', () => {
|
||||||
|
it('dispatches GetConversationsQuery', async () => {
|
||||||
|
mockQueryBus.execute.mockResolvedValue({ conversations: [], total: 0 });
|
||||||
|
|
||||||
|
await controller.getConversations(user as any, 20, 0);
|
||||||
|
|
||||||
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||||
|
expect.any(GetConversationsQuery),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMessages', () => {
|
||||||
|
it('dispatches GetMessagesQuery', async () => {
|
||||||
|
mockQueryBus.execute.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await controller.getMessages(user as any, 'conv-1', 50);
|
||||||
|
|
||||||
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||||
|
expect.any(GetMessagesQuery),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendMessage', () => {
|
||||||
|
it('dispatches SendMessageCommand', async () => {
|
||||||
|
const dto = { content: 'Hello!' };
|
||||||
|
mockCommandBus.execute.mockResolvedValue({ id: 'msg-1' });
|
||||||
|
|
||||||
|
const result = await controller.sendMessage(user as any, 'conv-1', dto as any);
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
||||||
|
expect.any(SendMessageCommand),
|
||||||
|
);
|
||||||
|
expect(result.id).toBe('msg-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markAsRead', () => {
|
||||||
|
it('dispatches MarkConversationReadCommand', async () => {
|
||||||
|
await controller.markAsRead(user as any, 'conv-1');
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
||||||
|
expect.any(MarkConversationReadCommand),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteMessage', () => {
|
||||||
|
it('calls messageRepo.softDelete with user id', async () => {
|
||||||
|
await controller.deleteMessage(user as any, 'msg-1');
|
||||||
|
|
||||||
|
expect(mockMessageRepo.softDelete).toHaveBeenCalledWith('msg-1', 'user-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { MessagingGateway } from '../gateways/messaging.gateway';
|
||||||
|
|
||||||
|
describe('MessagingGateway', () => {
|
||||||
|
let gateway: MessagingGateway;
|
||||||
|
let mockTokenService: { verifyAccessToken: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||||
|
let mockConversationRepo: {
|
||||||
|
findByUserId: ReturnType<typeof vi.fn>;
|
||||||
|
findById: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockTokenService = { verifyAccessToken: vi.fn() };
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||||
|
mockCommandBus = { execute: vi.fn() };
|
||||||
|
mockConversationRepo = {
|
||||||
|
findByUserId: vi.fn().mockResolvedValue([]),
|
||||||
|
findById: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway = new MessagingGateway(
|
||||||
|
mockTokenService as any,
|
||||||
|
mockLogger as any,
|
||||||
|
mockCommandBus as any,
|
||||||
|
mockConversationRepo as any,
|
||||||
|
);
|
||||||
|
gateway.server = {
|
||||||
|
to: vi.fn().mockReturnThis(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleConnection', () => {
|
||||||
|
it('authenticates client and joins rooms', async () => {
|
||||||
|
mockTokenService.verifyAccessToken.mockReturnValue({ sub: 'user-1', role: 'BUYER' });
|
||||||
|
mockConversationRepo.findByUserId.mockResolvedValue([
|
||||||
|
{ id: 'conv-1' },
|
||||||
|
{ id: 'conv-2' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
id: 'socket-1',
|
||||||
|
data: {},
|
||||||
|
handshake: { auth: { token: 'valid-token' }, headers: {}, query: {} },
|
||||||
|
join: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleConnection(client as any);
|
||||||
|
|
||||||
|
expect(client.data['userId']).toBe('user-1');
|
||||||
|
expect(client.join).toHaveBeenCalledWith('user:user-1');
|
||||||
|
expect(client.join).toHaveBeenCalledWith('conversation:conv-1');
|
||||||
|
expect(client.join).toHaveBeenCalledWith('conversation:conv-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects client with no token', async () => {
|
||||||
|
const client = {
|
||||||
|
id: 'socket-1',
|
||||||
|
data: {},
|
||||||
|
handshake: { auth: {}, headers: {}, query: {} },
|
||||||
|
join: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTokenService.verifyAccessToken.mockReturnValue(null);
|
||||||
|
|
||||||
|
await gateway.handleConnection(client as any);
|
||||||
|
|
||||||
|
expect(client.disconnect).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects client with invalid token', async () => {
|
||||||
|
mockTokenService.verifyAccessToken.mockReturnValue(null);
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
id: 'socket-1',
|
||||||
|
data: {},
|
||||||
|
handshake: { auth: { token: 'invalid' }, headers: {}, query: {} },
|
||||||
|
join: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleConnection(client as any);
|
||||||
|
|
||||||
|
expect(client.disconnect).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleDisconnect', () => {
|
||||||
|
it('cleans up socket tracking', () => {
|
||||||
|
// First connect
|
||||||
|
(gateway as any).userSockets.set('user-1', new Set(['socket-1']));
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
id: 'socket-1',
|
||||||
|
data: { userId: 'user-1' },
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.handleDisconnect(client as any);
|
||||||
|
|
||||||
|
expect((gateway as any).userSockets.has('user-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleMessageSent', () => {
|
||||||
|
it('emits message:new to conversation room', async () => {
|
||||||
|
const event = {
|
||||||
|
aggregateId: 'msg-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
senderId: 'user-1',
|
||||||
|
type: 'TEXT',
|
||||||
|
content: 'Hello!',
|
||||||
|
occurredAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleMessageSent(event as any);
|
||||||
|
|
||||||
|
expect(gateway.server.to).toHaveBeenCalledWith('conversation:conv-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSendMessage', () => {
|
||||||
|
it('sends message via command bus', async () => {
|
||||||
|
mockCommandBus.execute.mockResolvedValue({ id: 'msg-1' });
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
data: { userId: 'user-1' },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleSendMessage(
|
||||||
|
client as any,
|
||||||
|
{ conversationId: 'conv-1', content: 'Hello!' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCommandBus.execute).toHaveBeenCalled();
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('message:sent', {
|
||||||
|
messageId: 'msg-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits error when send fails', async () => {
|
||||||
|
mockCommandBus.execute.mockRejectedValue(new Error('Send failed'));
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
data: { userId: 'user-1' },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleSendMessage(
|
||||||
|
client as any,
|
||||||
|
{ conversationId: 'conv-1', content: 'Hello!' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('message:error', {
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
error: 'Send failed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleJoinConversation', () => {
|
||||||
|
it('joins room when user is participant', async () => {
|
||||||
|
mockConversationRepo.findById.mockResolvedValue({
|
||||||
|
participants: [{ userId: 'user-1' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
data: { userId: 'user-1' },
|
||||||
|
join: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleJoinConversation(client as any, { conversationId: 'conv-1' });
|
||||||
|
|
||||||
|
expect(client.join).toHaveBeenCalledWith('conversation:conv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not join room when user is not participant', async () => {
|
||||||
|
mockConversationRepo.findById.mockResolvedValue({
|
||||||
|
participants: [{ userId: 'user-2' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
data: { userId: 'user-1' },
|
||||||
|
join: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await gateway.handleJoinConversation(client as any, { conversationId: 'conv-1' });
|
||||||
|
|
||||||
|
expect(client.join).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Inject,
|
||||||
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||||
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { MessageType as PrismaMessageType } from '@prisma/client';
|
||||||
|
import { IsString, IsOptional, IsEnum, MaxLength } from 'class-validator';
|
||||||
|
import { CurrentUser, type JwtPayload } from '@modules/auth';
|
||||||
|
import { CreateConversationCommand } from '../../application/commands/create-conversation/create-conversation.command';
|
||||||
|
import { MarkConversationReadCommand } from '../../application/commands/mark-read/mark-read.command';
|
||||||
|
import { SendMessageCommand } from '../../application/commands/send-message/send-message.command';
|
||||||
|
import { GetConversationsQuery } from '../../application/queries/get-conversations/get-conversations.query';
|
||||||
|
import { GetMessagesQuery } from '../../application/queries/get-messages/get-messages.query';
|
||||||
|
import {
|
||||||
|
MESSAGE_REPOSITORY,
|
||||||
|
type IMessageRepository,
|
||||||
|
} from '../../domain/repositories/message.repository';
|
||||||
|
|
||||||
|
class CreateConversationDto {
|
||||||
|
@ApiProperty({ description: 'User ID of the other participant' })
|
||||||
|
@IsString()
|
||||||
|
participantUserId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Associated listing ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
listingId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Conversation subject' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(200)
|
||||||
|
subject?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Initial message text' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(5000)
|
||||||
|
initialMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SendMessageDto {
|
||||||
|
@ApiProperty({ description: 'Message content' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5000)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: PrismaMessageType, required: false, default: 'TEXT' })
|
||||||
|
@IsEnum(PrismaMessageType)
|
||||||
|
@IsOptional()
|
||||||
|
type?: PrismaMessageType;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Additional metadata (e.g. file URL)' })
|
||||||
|
@IsOptional()
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('messaging')
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@Controller('messaging')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
export class MessagingController {
|
||||||
|
constructor(
|
||||||
|
private readonly commandBus: CommandBus,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
@Inject(MESSAGE_REPOSITORY)
|
||||||
|
private readonly messageRepo: IMessageRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('conversations')
|
||||||
|
@ApiOperation({ summary: 'Create a conversation or return existing one' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Conversation created' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Cannot create conversation with yourself' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async createConversation(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: CreateConversationDto,
|
||||||
|
) {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new CreateConversationCommand(
|
||||||
|
user.sub,
|
||||||
|
dto.participantUserId,
|
||||||
|
dto.listingId,
|
||||||
|
dto.subject,
|
||||||
|
dto.initialMessage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('conversations')
|
||||||
|
@ApiOperation({ summary: 'List user conversations' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Conversations retrieved' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||||
|
async getConversations(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
@Query('offset') offset?: number,
|
||||||
|
) {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetConversationsQuery(user.sub, limit ?? 20, offset ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('conversations/:id/messages')
|
||||||
|
@ApiOperation({ summary: 'Get messages in a conversation' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Messages retrieved' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Not a participant' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Conversation not found' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'before', required: false, type: String, description: 'Cursor: message ID for pagination' })
|
||||||
|
async getMessages(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id') conversationId: string,
|
||||||
|
@Query('limit') limit?: number,
|
||||||
|
@Query('before') before?: string,
|
||||||
|
) {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetMessagesQuery(conversationId, user.sub, limit ?? 50, before),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('conversations/:id/messages')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Send a message in a conversation' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Message sent' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Not a participant' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Conversation not found' })
|
||||||
|
async sendMessage(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id') conversationId: string,
|
||||||
|
@Body() dto: SendMessageDto,
|
||||||
|
) {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new SendMessageCommand(
|
||||||
|
conversationId,
|
||||||
|
user.sub,
|
||||||
|
dto.content,
|
||||||
|
dto.type ?? 'TEXT',
|
||||||
|
dto.metadata,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('conversations/:id/read')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Mark a conversation as read' })
|
||||||
|
@ApiResponse({ status: 204, description: 'Conversation marked as read' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Not a participant' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Conversation not found' })
|
||||||
|
async markAsRead(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('id') conversationId: string,
|
||||||
|
) {
|
||||||
|
await this.commandBus.execute(
|
||||||
|
new MarkConversationReadCommand(conversationId, user.sub),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('conversations/:conversationId/messages/:messageId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Soft-delete a message (sender only)' })
|
||||||
|
@ApiResponse({ status: 204, description: 'Message deleted' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async deleteMessage(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Param('messageId') messageId: string,
|
||||||
|
) {
|
||||||
|
await this.messageRepo.softDelete(messageId, user.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||||
|
import { CommandBus } from '@nestjs/cqrs';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
MessageBody,
|
||||||
|
ConnectedSocket,
|
||||||
|
type OnGatewayConnection,
|
||||||
|
type OnGatewayDisconnect,
|
||||||
|
type OnGatewayInit,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import type { Server, Socket } from 'socket.io';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||||
|
import { TokenService, type JwtPayload } from '@modules/auth';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { MarkConversationReadCommand } from '../../application/commands/mark-read/mark-read.command';
|
||||||
|
import { SendMessageCommand } from '../../application/commands/send-message/send-message.command';
|
||||||
|
import type { MessageSentEvent } from '../../domain/events/message-sent.event';
|
||||||
|
import {
|
||||||
|
CONVERSATION_REPOSITORY,
|
||||||
|
type IConversationRepository,
|
||||||
|
} from '../../domain/repositories/conversation.repository';
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
namespace: '/messaging',
|
||||||
|
cors: {
|
||||||
|
origin: (process.env['CORS_ORIGINS'] ?? 'http://localhost:3000')
|
||||||
|
.split(',')
|
||||||
|
.map((o) => o.trim()),
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class MessagingGateway
|
||||||
|
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||||
|
{
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
private readonly userSockets = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
private readonly commandBus: CommandBus,
|
||||||
|
@Inject(CONVERSATION_REPOSITORY)
|
||||||
|
private readonly conversationRepo: IConversationRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
afterInit(): void {
|
||||||
|
this.logger.log('MessagingGateway initialized', 'MessagingGateway');
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConnection(client: Socket): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload = this.extractAndVerifyToken(client);
|
||||||
|
if (!payload) {
|
||||||
|
client.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.data['userId'] = payload.sub;
|
||||||
|
|
||||||
|
// Join user's personal room for direct messaging events
|
||||||
|
await client.join(`user:${payload.sub}`);
|
||||||
|
|
||||||
|
// Join rooms for all active conversations
|
||||||
|
const conversations = await this.conversationRepo.findByUserId(payload.sub, 100);
|
||||||
|
for (const conv of conversations) {
|
||||||
|
await client.join(`conversation:${conv.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.userSockets.has(payload.sub)) {
|
||||||
|
this.userSockets.set(payload.sub, new Set());
|
||||||
|
}
|
||||||
|
this.userSockets.get(payload.sub)!.add(client.id);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`WS messaging connected: user=${payload.sub} socket=${client.id} conversations=${conversations.length}`,
|
||||||
|
'MessagingGateway',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`WS messaging connection error: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'MessagingGateway',
|
||||||
|
);
|
||||||
|
client.disconnect(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket): void {
|
||||||
|
const userId = client.data['userId'] as string | undefined;
|
||||||
|
if (userId) {
|
||||||
|
const sockets = this.userSockets.get(userId);
|
||||||
|
if (sockets) {
|
||||||
|
sockets.delete(client.id);
|
||||||
|
if (sockets.size === 0) {
|
||||||
|
this.userSockets.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.debug(
|
||||||
|
`WS messaging disconnected: user=${userId ?? 'unknown'} socket=${client.id}`,
|
||||||
|
'MessagingGateway',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────
|
||||||
|
* Client → Server message handlers
|
||||||
|
* ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@SubscribeMessage('message:send')
|
||||||
|
async handleSendMessage(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { conversationId: string; content: string; type?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = client.data['userId'] as string;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await this.commandBus.execute(
|
||||||
|
new SendMessageCommand(
|
||||||
|
data.conversationId,
|
||||||
|
userId,
|
||||||
|
data.content,
|
||||||
|
(data.type as 'TEXT' | 'IMAGE' | 'FILE') ?? 'TEXT',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
client.emit('message:sent', { messageId: message.id, conversationId: data.conversationId });
|
||||||
|
} catch (error) {
|
||||||
|
client.emit('message:error', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
error: error instanceof Error ? error.message : 'Không thể gửi tin nhắn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('message:typing')
|
||||||
|
async handleTyping(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { conversationId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = client.data['userId'] as string;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
client.to(`conversation:${data.conversationId}`).emit('message:typing', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('message:stop-typing')
|
||||||
|
async handleStopTyping(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { conversationId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = client.data['userId'] as string;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
client.to(`conversation:${data.conversationId}`).emit('message:stop-typing', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('conversation:read')
|
||||||
|
async handleMarkRead(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { conversationId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = client.data['userId'] as string;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.commandBus.execute(
|
||||||
|
new MarkConversationReadCommand(data.conversationId, userId),
|
||||||
|
);
|
||||||
|
client.emit('conversation:read-ack', { conversationId: data.conversationId });
|
||||||
|
} catch {
|
||||||
|
// Non-critical — silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('conversation:join')
|
||||||
|
async handleJoinConversation(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { conversationId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = client.data['userId'] as string;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// Verify the user is a participant before joining the room
|
||||||
|
const conversation = await this.conversationRepo.findById(data.conversationId);
|
||||||
|
if (conversation && conversation.participants.some((p) => p.userId === userId)) {
|
||||||
|
await client.join(`conversation:${data.conversationId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────
|
||||||
|
* Domain event handlers
|
||||||
|
* ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@OnEvent('message.sent', { async: true })
|
||||||
|
async handleMessageSent(event: MessageSentEvent): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.server.to(`conversation:${event.conversationId}`).emit('message:new', {
|
||||||
|
id: event.aggregateId,
|
||||||
|
conversationId: event.conversationId,
|
||||||
|
senderId: event.senderId,
|
||||||
|
type: event.type,
|
||||||
|
content: event.content,
|
||||||
|
createdAt: event.occurredAt.toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to emit WS message for conversation ${event.conversationId}: ${
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'MessagingGateway',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────
|
||||||
|
* Private helpers
|
||||||
|
* ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
private extractAndVerifyToken(client: Socket): JwtPayload | null {
|
||||||
|
const raw: unknown =
|
||||||
|
client.handshake.auth?.['token'] ??
|
||||||
|
client.handshake.headers?.['authorization'] ??
|
||||||
|
client.handshake.query?.['token'];
|
||||||
|
|
||||||
|
if (!raw || typeof raw !== 'string') {
|
||||||
|
this.logger.warn(
|
||||||
|
`WS messaging auth failed: no token provided (socket=${client.id})`,
|
||||||
|
'MessagingGateway',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = raw.startsWith('Bearer ') ? raw.slice(7) : raw;
|
||||||
|
const payload = this.tokenService.verifyAccessToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
this.logger.warn(
|
||||||
|
`WS messaging auth failed: invalid token (socket=${client.id})`,
|
||||||
|
'MessagingGateway',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -880,3 +880,212 @@ model Review {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([targetType, targetId, createdAt(sort: Desc)])
|
@@index([targetType, targetId, createdAt(sort: Desc)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// INDUSTRIAL PARKS (KCN)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum IndustrialParkStatus {
|
||||||
|
PLANNING
|
||||||
|
UNDER_CONSTRUCTION
|
||||||
|
OPERATIONAL
|
||||||
|
FULL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IndustrialPropertyType {
|
||||||
|
INDUSTRIAL_LAND
|
||||||
|
READY_BUILT_FACTORY
|
||||||
|
READY_BUILT_WAREHOUSE
|
||||||
|
LOGISTICS_CENTER
|
||||||
|
OFFICE_IN_PARK
|
||||||
|
DATA_CENTER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IndustrialLeaseType {
|
||||||
|
LAND_LEASE
|
||||||
|
FACTORY_LEASE
|
||||||
|
WAREHOUSE_LEASE
|
||||||
|
SUBLEASE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IndustrialListingStatus {
|
||||||
|
DRAFT
|
||||||
|
ACTIVE
|
||||||
|
RESERVED
|
||||||
|
LEASED
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VietnamRegion {
|
||||||
|
NORTH
|
||||||
|
CENTRAL
|
||||||
|
SOUTH
|
||||||
|
}
|
||||||
|
|
||||||
|
model IndustrialPark {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
nameEn String?
|
||||||
|
slug String @unique
|
||||||
|
developer String
|
||||||
|
operator String?
|
||||||
|
status IndustrialParkStatus @default(PLANNING)
|
||||||
|
location Unsupported("geometry(Point, 4326)")
|
||||||
|
address String
|
||||||
|
district String
|
||||||
|
province String
|
||||||
|
region VietnamRegion
|
||||||
|
totalAreaHa Float
|
||||||
|
leasableAreaHa Float
|
||||||
|
occupancyRate Float @default(0) // 0-100
|
||||||
|
remainingAreaHa Float
|
||||||
|
tenantCount Int @default(0)
|
||||||
|
establishedYear Int?
|
||||||
|
landRentUsdM2Year Float?
|
||||||
|
rbfRentUsdM2Month Float?
|
||||||
|
rbwRentUsdM2Month Float?
|
||||||
|
managementFeeUsd Float?
|
||||||
|
infrastructure Json? // { electricity, water, wastewater, telecom, roads, fire }
|
||||||
|
connectivity Json? // { nearestPort, airport, highway, railway, seaport }
|
||||||
|
incentives Json? // { taxHoliday, importDuty, landRentReduction, specialZone }
|
||||||
|
targetIndustries String[]
|
||||||
|
existingTenants Json? // [{ name, country, industry }]
|
||||||
|
certifications Json? // ["ISO 14001", "Green park"]
|
||||||
|
media Json?
|
||||||
|
documents Json?
|
||||||
|
description String? @db.Text
|
||||||
|
descriptionEn String? @db.Text
|
||||||
|
isVerified Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
listings IndustrialListing[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@index([province])
|
||||||
|
@@index([region])
|
||||||
|
@@index([developer])
|
||||||
|
@@index([location], type: Gist)
|
||||||
|
@@index([isVerified])
|
||||||
|
@@index([occupancyRate])
|
||||||
|
@@index([landRentUsdM2Year])
|
||||||
|
@@index([region, province, status])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model IndustrialListing {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
parkId String
|
||||||
|
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
|
||||||
|
agentId String?
|
||||||
|
sellerId String
|
||||||
|
propertyType IndustrialPropertyType
|
||||||
|
leaseType IndustrialLeaseType
|
||||||
|
status IndustrialListingStatus @default(DRAFT)
|
||||||
|
title String
|
||||||
|
description String? @db.Text
|
||||||
|
areaM2 Float
|
||||||
|
ceilingHeightM Float?
|
||||||
|
floorLoadTonM2 Float?
|
||||||
|
columnSpacingM Float?
|
||||||
|
dockCount Int?
|
||||||
|
craneCapacityTon Float?
|
||||||
|
hasMezzanine Boolean @default(false)
|
||||||
|
hasOfficeArea Boolean @default(false)
|
||||||
|
officeAreaM2 Float?
|
||||||
|
priceUsdM2 Float?
|
||||||
|
pricingUnit String? // "usd/m2/month", "usd/m2/year"
|
||||||
|
totalLeasePrice Float?
|
||||||
|
managementFee Float?
|
||||||
|
depositMonths Int?
|
||||||
|
minLeaseYears Int?
|
||||||
|
maxLeaseYears Int?
|
||||||
|
leaseExpiry DateTime?
|
||||||
|
availableFrom DateTime?
|
||||||
|
powerCapacityKva Float?
|
||||||
|
waterSupplyM3Day Float?
|
||||||
|
media Json?
|
||||||
|
viewCount Int @default(0)
|
||||||
|
inquiryCount Int @default(0)
|
||||||
|
publishedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([parkId])
|
||||||
|
@@index([propertyType])
|
||||||
|
@@index([leaseType])
|
||||||
|
@@index([status])
|
||||||
|
@@index([areaM2])
|
||||||
|
@@index([priceUsdM2])
|
||||||
|
@@index([sellerId])
|
||||||
|
@@index([agentId])
|
||||||
|
@@index([publishedAt])
|
||||||
|
@@index([parkId, status])
|
||||||
|
@@index([propertyType, leaseType, status])
|
||||||
|
@@index([status, publishedAt(sort: Desc)])
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MESSAGING (buyer ↔ agent / seller in-app chat)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum ConversationStatus {
|
||||||
|
ACTIVE
|
||||||
|
ARCHIVED
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Conversation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listingId String?
|
||||||
|
subject String?
|
||||||
|
status ConversationStatus @default(ACTIVE)
|
||||||
|
lastMessage String? @db.Text
|
||||||
|
lastMessageAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
participants ConversationParticipant[]
|
||||||
|
messages Message[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@index([lastMessageAt(sort: Desc)])
|
||||||
|
@@index([listingId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ConversationParticipant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
conversationId String
|
||||||
|
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
unreadCount Int @default(0)
|
||||||
|
lastReadAt DateTime?
|
||||||
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([conversationId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([conversationId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MessageType {
|
||||||
|
TEXT
|
||||||
|
IMAGE
|
||||||
|
FILE
|
||||||
|
SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
model Message {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
conversationId String
|
||||||
|
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
senderId String
|
||||||
|
type MessageType @default(TEXT)
|
||||||
|
content String @db.Text
|
||||||
|
metadata Json?
|
||||||
|
editedAt DateTime?
|
||||||
|
deletedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([conversationId, createdAt])
|
||||||
|
@@index([senderId])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user