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:
@@ -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,
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user