Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions backend/src/ Message/ Message-thread.entity.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
48 changes: 48 additions & 0 deletions backend/src/ Message/Message.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
84 changes: 84 additions & 0 deletions backend/src/ Message/Messaging.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
43 changes: 43 additions & 0 deletions backend/src/ Message/Messaging.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions backend/src/ Message/Messaging.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading