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:
Ho Ngoc Hai
2026-04-16 05:36:04 +07:00
parent 30d3039b94
commit 3b5da2dcf9
37 changed files with 2310 additions and 0 deletions

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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!',
}),
);
});
});

View File

@@ -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,
) {}
}

View File

@@ -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');
}
}
}

View File

@@ -0,0 +1,6 @@
export class MarkConversationReadCommand {
constructor(
public readonly conversationId: string,
public readonly userId: string,
) {}
}

View File

@@ -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');
}
}
}

View File

@@ -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>,
) {}
}

View File

@@ -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');
}
}
}

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,7 @@
export class GetConversationsQuery {
constructor(
public readonly userId: string,
public readonly limit: number = 20,
public readonly offset: number = 0,
) {}
}

View File

@@ -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);
}
}

View File

@@ -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,
) {}
}