diff --git a/backend/src/ Message/ Message-thread.entity.ts b/backend/src/ Message/ Message-thread.entity.ts new file mode 100644 index 0000000..1827596 --- /dev/null +++ b/backend/src/ Message/ Message-thread.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToMany, + CreateDateColumn, + Index, +} from 'typeorm'; +import { Message } from './message.entity'; + +@Entity('message_threads') +@Index(['lastMessageAt']) +export class MessageThread { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** + * JSON array of User UUIDs who are participants in this thread. + * Stored as JSONB for flexible multi-participant support. + */ + @Column({ type: 'jsonb' }) + participantIds: string[]; + + @Column({ type: 'timestamptz', nullable: true }) + lastMessageAt: Date | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @OneToMany(() => Message, (msg) => msg.thread) + messages: Message[]; +} \ No newline at end of file diff --git a/backend/src/ Message/Message.entity.ts b/backend/src/ Message/Message.entity.ts new file mode 100644 index 0000000..0f468d6 --- /dev/null +++ b/backend/src/ Message/Message.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + Index, +} from 'typeorm'; +import { MessageThread } from './message-thread.entity'; +import { User } from '../../../users/entities/user.entity'; + +@Entity('messages') +@Index(['threadId']) +@Index(['senderUserId']) +@Index(['isRead']) +export class Message { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + threadId: string; + + @ManyToOne(() => MessageThread, (thread) => thread.messages, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'threadId' }) + thread: MessageThread; + + @Column({ type: 'uuid' }) + senderUserId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'senderUserId' }) + sender: User; + + @Column({ type: 'text' }) + body: string; + + /** + * Tracks whether the message has been read. + * Note: in a multi-participant thread, this is a per-message global flag. + * For per-user read receipts, extend to a separate read_receipts table. + */ + @Column({ type: 'boolean', default: false }) + isRead: boolean; + + @CreateDateColumn({ type: 'timestamptz' }) + sentAt: Date; +} \ No newline at end of file diff --git a/backend/src/ Message/Messaging.controller.ts b/backend/src/ Message/Messaging.controller.ts new file mode 100644 index 0000000..3befe4d --- /dev/null +++ b/backend/src/ Message/Messaging.controller.ts @@ -0,0 +1,84 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { MessagingService } from './messaging.service'; +import { CurrentUser } from '../auth/decorators/current.user.decorators'; +import { User } from '../users/entities/user.entity'; +import { CreateThreadDto, MessagePaginationDto, SendMessageDto } from './dto/messaging.dto'; + +@Controller('messages') +export class MessagingController { + constructor(private readonly messagingService: MessagingService) {} + + /** + * POST /messages/threads + * Start a new thread with one or more participants. + */ + @Post('threads') + createThread(@Body() dto: CreateThreadDto, @CurrentUser() user: User) { + return this.messagingService.createThread(dto, user.id); + } + + /** + * GET /messages/threads + * List all threads for the current user, sorted by lastMessageAt desc. + */ + @Get('threads') + listThreads(@CurrentUser() user: User) { + return this.messagingService.listThreads(user.id); + } + + /** + * GET /messages/threads/:id/messages + * Paginated message history for a thread (participants only). + */ + @Get('threads/:id/messages') + getMessages( + @Param('id', ParseUUIDPipe) threadId: string, + @CurrentUser() user: User, + @Query() pagination: MessagePaginationDto, + ) { + return this.messagingService.getThreadMessages(threadId, user.id, pagination); + } + + /** + * POST /messages/threads/:id/messages + * Send a message to a thread; emits new-message WS event. + */ + @Post('threads/:id/messages') + sendMessage( + @Param('id', ParseUUIDPipe) threadId: string, + @Body() dto: SendMessageDto, + @CurrentUser() user: User, + ) { + return this.messagingService.sendMessage(threadId, dto, user.id); + } + + /** + * PATCH /messages/threads/:id/read + * Mark all unread messages in the thread as read. + */ + @Patch('threads/:id/read') + markRead( + @Param('id', ParseUUIDPipe) threadId: string, + @CurrentUser() user: User, + ) { + return this.messagingService.markAsRead(threadId, user.id); + } + + /** + * GET /messages/unread-count + * Returns the total unread message count for badge display. + */ + @Get('unread-count') + unreadCount(@CurrentUser() user: User) { + return this.messagingService.getUnreadCount(user.id); + } +} \ No newline at end of file diff --git a/backend/src/ Message/Messaging.dto.ts b/backend/src/ Message/Messaging.dto.ts new file mode 100644 index 0000000..35ef21b --- /dev/null +++ b/backend/src/ Message/Messaging.dto.ts @@ -0,0 +1,43 @@ +import { + IsArray, + IsNotEmpty, + IsString, + IsUUID, + ArrayMinSize, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsOptional, IsInt, Min, Max } from 'class-validator'; + +// ── Create Thread ───────────────────────────────────────────────────────── + +export class CreateThreadDto { + @IsArray() + @ArrayMinSize(1, { message: 'At least one participant is required' }) + @IsUUID('4', { each: true }) + participantIds: string[]; +} + +// ── Send Message ────────────────────────────────────────────────────────── + +export class SendMessageDto { + @IsString() + @IsNotEmpty({ message: 'Message body cannot be empty' }) + body: string; +} + +// ── Paginate Messages ───────────────────────────────────────────────────── + +export class MessagePaginationDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} \ No newline at end of file diff --git a/backend/src/ Message/Messaging.module.ts b/backend/src/ Message/Messaging.module.ts new file mode 100644 index 0000000..e26d8fa --- /dev/null +++ b/backend/src/ Message/Messaging.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MessageThread } from './entities/message-thread.entity'; +import { Message } from './entities/message.entity'; +import { MessagingService } from './messaging.service'; +import { MessagingController } from './messaging.controller'; +import { NotificationsModule } from '../notifications/notifications.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([MessageThread, Message]), + NotificationsModule, // provides NotificationsGateway for WS emit + ], + controllers: [MessagingController], + providers: [MessagingService], + exports: [MessagingService], +}) +export class MessagingModule {} diff --git a/backend/src/ Message/Messaging.service.ts b/backend/src/ Message/Messaging.service.ts new file mode 100644 index 0000000..70640ba --- /dev/null +++ b/backend/src/ Message/Messaging.service.ts @@ -0,0 +1,170 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MessageThread } from './entities/message-thread.entity'; +import { Message } from './entities/message.entity'; +import { CreateThreadDto, SendMessageDto, MessagePaginationDto } from './dto/messaging.dto'; +import { NotificationsGateway } from '../notifications/gateway/notifications.gateway'; + +@Injectable() +export class MessagingService { + constructor( + @InjectRepository(MessageThread) + private readonly threadRepo: Repository, + @InjectRepository(Message) + private readonly messageRepo: Repository, + private readonly notificationsGateway: NotificationsGateway, + ) {} + + // ── POST /messages/threads ───────────────────────────────────────────── + + async createThread(dto: CreateThreadDto, currentUserId: string): Promise { + // Always include the creator in the participant list + const participantSet = new Set([currentUserId, ...dto.participantIds]); + const participantIds = Array.from(participantSet); + + const thread = this.threadRepo.create({ participantIds, lastMessageAt: null }); + return this.threadRepo.save(thread); + } + + // ── GET /messages/threads ────────────────────────────────────────────── + + async listThreads(userId: string): Promise { + // Load threads the user participates in, with the latest message preview + const threads = await this.threadRepo + .createQueryBuilder('thread') + .where(':userId = ANY(thread.participantIds)', { userId }) + .orderBy('thread.lastMessageAt', 'DESC', 'NULLS LAST') + .getMany(); + + // Fetch the last message for each thread + const results = await Promise.all( + threads.map(async (thread) => { + const lastMessage = await this.messageRepo.findOne({ + where: { threadId: thread.id }, + order: { sentAt: 'DESC' }, + select: ['id', 'body', 'senderUserId', 'sentAt', 'isRead'], + }); + return { ...thread, lastMessage: lastMessage ?? null }; + }), + ); + + return results; + } + + // ── GET /messages/threads/:id/messages ───────────────────────────────── + + async getThreadMessages( + threadId: string, + userId: string, + pagination: MessagePaginationDto, + ): Promise<{ data: Message[]; total: number; page: number; limit: number }> { + const thread = await this.findThreadOrThrow(threadId); + this.assertParticipant(thread, userId); + + const page = pagination.page ?? 1; + const limit = pagination.limit ?? 20; + const skip = (page - 1) * limit; + + const [data, total] = await this.messageRepo.findAndCount({ + where: { threadId }, + order: { sentAt: 'ASC' }, + skip, + take: limit, + }); + + return { data, total, page, limit }; + } + + // ── POST /messages/threads/:id/messages ──────────────────────────────── + + async sendMessage( + threadId: string, + dto: SendMessageDto, + senderUserId: string, + ): Promise { + const thread = await this.findThreadOrThrow(threadId); + this.assertParticipant(thread, senderUserId); + + const message = this.messageRepo.create({ + threadId, + senderUserId, + body: dto.body, + }); + const saved = await this.messageRepo.save(message); + + // Update thread.lastMessageAt + await this.threadRepo.update(threadId, { lastMessageAt: saved.sentAt }); + + // Emit new-message WebSocket event to all participants + const payload = { + event: 'new-message', + threadId, + message: { id: saved.id, body: saved.body, senderUserId, sentAt: saved.sentAt }, + }; + + for (const participantId of thread.participantIds) { + this.notificationsGateway.server + ?.to(`user:${participantId}`) + .emit('new-message', payload); + } + + return saved; + } + + // ── PATCH /messages/threads/:id/read ────────────────────────────────── + + async markAsRead(threadId: string, userId: string): Promise<{ updated: number }> { + const thread = await this.findThreadOrThrow(threadId); + this.assertParticipant(thread, userId); + + const result = await this.messageRepo.update( + { threadId, isRead: false }, + { isRead: true }, + ); + + return { updated: result.affected ?? 0 }; + } + + // ── GET /messages/unread-count ───────────────────────────────────────── + + async getUnreadCount(userId: string): Promise<{ unreadCount: number }> { + // Find all threads the user participates in + const threads = await this.threadRepo + .createQueryBuilder('thread') + .select('thread.id') + .where(':userId = ANY(thread.participantIds)', { userId }) + .getMany(); + + if (threads.length === 0) return { unreadCount: 0 }; + + const threadIds = threads.map((t) => t.id); + + const unreadCount = await this.messageRepo + .createQueryBuilder('msg') + .where('msg.threadId IN (:...threadIds)', { threadIds }) + .andWhere('msg.isRead = false') + .andWhere('msg.senderUserId != :userId', { userId }) // don't count own messages + .getCount(); + + return { unreadCount }; + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private async findThreadOrThrow(threadId: string): Promise { + const thread = await this.threadRepo.findOne({ where: { id: threadId } }); + if (!thread) throw new NotFoundException(`Thread ${threadId} not found`); + return thread; + } + + private assertParticipant(thread: MessageThread, userId: string): void { + if (!thread.participantIds.includes(userId)) { + throw new ForbiddenException('You are not a participant in this thread'); + } + } +} \ No newline at end of file