diff --git a/src/app.module.ts b/src/app.module.ts index 84964233..cd6b1818 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,8 +29,12 @@ import { AlertsModule } from "./alerts/alerts.module"; import { MetricsModule } from "./metrics/metrics.module"; import { AnalyticsModule } from "./analytics/analytics.module"; import { RateLimitModule } from "./quota/rate-limit.module"; +import { MessagingModule } from "./messaging/messaging.module"; // Auth entities +import { Conversation } from "./messaging/entities/conversation.entity"; +import { Message } from "./messaging/entities/message.entity"; +import { UserPresence } from "./messaging/entities/user-presence.entity"; import { User } from "./user/entities/user.entity"; import { EmailVerification } from "./auth/entities/email-verification.entity"; import { Wallet } from "./auth/entities/wallet.entity"; @@ -118,6 +122,9 @@ import { QuotaGuard } from "./common/guard/quota.guard"; AlertDeliveryLog, AnalyticsEvent, DailyMetric, + Conversation, + Message, + UserPresence, ], synchronize: !isProduction, logging: isProduction ? ["error"] : ["error", "warn", "schema"], @@ -154,6 +161,7 @@ import { QuotaGuard } from "./common/guard/quota.guard"; MetricsModule, AnalyticsModule, RateLimitModule, + MessagingModule, ], controllers: [AppController], @@ -203,4 +211,4 @@ export class AppModule implements NestModule, OnModuleInit { onModuleInit() { this.verifier.start(); } -} +} \ No newline at end of file diff --git a/src/messaging/MESSAGING_SCALING.md b/src/messaging/MESSAGING_SCALING.md new file mode 100644 index 00000000..31050560 --- /dev/null +++ b/src/messaging/MESSAGING_SCALING.md @@ -0,0 +1,82 @@ +# Messaging Module: Scaling Strategy & Infrastructure + +## Overview +The StellAIverse messaging module uses Socket.IO for real-time WebSocket communication, combined with PostgreSQL for persistence and Redis for horizontal scaling. This document outlines the infrastructure requirements and scaling strategy to support a growing user base. + +## Required Infrastructure + +### 1. Redis (Required for Horizontal Scaling) +Socket.IO requires a Redis adapter to enable broadcasting across multiple backend instances. Redis acts as a pub/sub layer for message distribution. + +#### Redis Setup: +```typescript +// In messaging.module.ts add the Redis adapter +import { RedisAdapter } from "@socket.io/redis-adapter"; +import { createClient } from "redis"; + +// Then in the MessagingGateway after server initialization: +const pubClient = createClient({ url: process.env.REDIS_URL }); +const subClient = pubClient.duplicate(); +await Promise.all([pubClient.connect(), subClient.connect()]); +this.server.adapter(createAdapter(pubClient, subClient)); +``` + +### 2. PostgreSQL (Current Persistence Layer) +We currently use PostgreSQL for storing all conversations, messages, and presence data. This is sufficient for up to millions of messages, but for larger scale we can implement the following optimizations: + +### 3. Additional Recommended Infrastructure +- **S3/Object Storage**: For archiving old messages (older than 1 year) +- **Full-text Search Service**: For implementing message search functionality (Elasticsearch or Typesense) + +## Message Lifecycle & Cleanup Strategy + +### Persistence Rules: +1. **Active Messages**: Last 12 months stored in PostgreSQL +2. **Archived Messages**: Older than 12 months moved to S3/Glacier +3. **Cron Job**: Run `messagingService.archiveOldMessages()` monthly + +### Implementation: +```typescript +// Add a cron service to run monthly archiving +import { Cron } from "@nestjs/schedule"; + +@Cron("0 0 1 * *") // Run on the 1st of every month +async handleArchiving() { + const archivedCount = await this.messagingService.archiveOldMessages(); + logger.log(`Archived ${archivedCount} old messages`); +} +``` + +## WebSocket Connection Flow +1. Client connects to `wss://api.example.com/messaging` +2. Client sends JWT token in handshake +3. Server validates token, attaches user to socket +4. Server adds user to their personal room: `user:{userId}` +5. When joining a conversation, server adds socket to `conversation:{conversationId}` + +## Reconnection & Message Guarantee +To handle dropped connections and ensure no message loss: +1. **Client-side buffering**: Messages are queued when disconnected +2. **Message IDs**: Every message gets a UUID client-side to prevent duplicates +3. **Last seen synchronization**: On reconnection, client fetches all messages since last seen +4. **At-least-once delivery**: Server persists messages before broadcasting + +## Scaling to Multiple Instances +1. Deploy behind a load balancer that supports sticky sessions (or use Redis adapter which removes this requirement) +2. Use the Redis adapter to enable cross-instance communication +3. Implement connection draining during deployments to avoid abrupt disconnections +4. Monitor connection counts per instance, scale out when approaching 10k connections per instance + +## Monitoring & Metrics +Key metrics to track: +- Active connections per instance +- Messages sent/sec +- Delivery receipts latency +- Archive job success rate +- WebSocket error rates + +## Testing Strategy +1. **Unit tests**: Test all service methods +2. **Integration tests**: Test WebSocket connection, message flow +3. **Load tests**: Simulate thousands of concurrent users +4. **Chaos tests**: Simulate network splits, instance failures \ No newline at end of file diff --git a/src/messaging/dto/create-conversation.dto.ts b/src/messaging/dto/create-conversation.dto.ts new file mode 100644 index 00000000..1f797610 --- /dev/null +++ b/src/messaging/dto/create-conversation.dto.ts @@ -0,0 +1,19 @@ +import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from "class-validator"; +import { ConversationType } from "../entities/message.enum"; + +export class CreateConversationDto { + @IsArray() + @IsUUID("4", { each: true }) + participantIds: string[]; + + @IsEnum(ConversationType) + type: ConversationType; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + avatar?: string; +} \ No newline at end of file diff --git a/src/messaging/dto/get-messages.dto.ts b/src/messaging/dto/get-messages.dto.ts new file mode 100644 index 00000000..add3b861 --- /dev/null +++ b/src/messaging/dto/get-messages.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsUUID, IsInt, Min } from "class-validator"; +import { Type } from "class-transformer"; + +export class GetMessagesDto { + @IsOptional() + @IsUUID() + before?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + limit?: number = 50; +} \ No newline at end of file diff --git a/src/messaging/dto/send-message.dto.ts b/src/messaging/dto/send-message.dto.ts new file mode 100644 index 00000000..18f11add --- /dev/null +++ b/src/messaging/dto/send-message.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsUUID, IsOptional, IsObject } from "class-validator"; + +export class SendMessageDto { + @IsUUID() + conversationId: string; + + @IsString() + content: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} \ No newline at end of file diff --git a/src/messaging/entities/conversation.entity.ts b/src/messaging/entities/conversation.entity.ts new file mode 100644 index 00000000..e4f3acec --- /dev/null +++ b/src/messaging/entities/conversation.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToMany, + JoinTable, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; +import { User } from "../../user/entities/user.entity"; +import { Message } from "./message.entity"; +import { ConversationType } from "./message.enum"; + +@Entity() +export class Conversation { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ + type: "enum", + enum: ConversationType, + default: ConversationType.PRIVATE, + }) + type: ConversationType; + + @Column({ nullable: true }) + name?: string; + + @Column({ nullable: true }) + avatar?: string; + + @ManyToMany(() => User) + @JoinTable() + participants: User[]; + + @OneToMany(() => Message, (message) => message.conversation) + messages: Message[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ nullable: true }) + lastMessageAt?: Date; +} \ No newline at end of file diff --git a/src/messaging/entities/message.entity.ts b/src/messaging/entities/message.entity.ts new file mode 100644 index 00000000..099e9ca0 --- /dev/null +++ b/src/messaging/entities/message.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; +import { User } from "../../user/entities/user.entity"; +import { Conversation } from "./conversation.entity"; +import { MessageStatus } from "./message.enum"; + +@Entity() +export class Message { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column("text") + content: string; + + @ManyToOne(() => User) + sender: User; + + @ManyToOne(() => Conversation, (conversation) => conversation.messages) + conversation: Conversation; + + @Column({ + type: "enum", + enum: MessageStatus, + default: MessageStatus.SENT, + }) + status: MessageStatus; + + @Column({ type: "jsonb", nullable: true }) + metadata?: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ nullable: true }) + deliveredAt?: Date; + + @Column({ nullable: true }) + readAt?: Date; +} \ No newline at end of file diff --git a/src/messaging/entities/message.enum.ts b/src/messaging/entities/message.enum.ts new file mode 100644 index 00000000..542ecf6f --- /dev/null +++ b/src/messaging/entities/message.enum.ts @@ -0,0 +1,10 @@ +export enum MessageStatus { + SENT = "sent", + DELIVERED = "delivered", + READ = "read", +} + +export enum ConversationType { + PRIVATE = "private", + GROUP = "group", +} \ No newline at end of file diff --git a/src/messaging/entities/user-presence.entity.ts b/src/messaging/entities/user-presence.entity.ts new file mode 100644 index 00000000..6abc11da --- /dev/null +++ b/src/messaging/entities/user-presence.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; +import { User } from "../../user/entities/user.entity"; + +@Entity() +export class UserPresence { + @PrimaryGeneratedColumn("uuid") + id: string; + + @OneToOne(() => User) + @JoinColumn() + user: User; + + @Column({ default: false }) + isOnline: boolean; + + @Column({ nullable: true }) + lastSeenAt?: Date; + + @Column({ nullable: true }) + currentSocketId?: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/messaging/events/message.events.ts b/src/messaging/events/message.events.ts new file mode 100644 index 00000000..f9fbb1d5 --- /dev/null +++ b/src/messaging/events/message.events.ts @@ -0,0 +1,31 @@ +export class MessageSentEvent { + constructor( + public readonly message: any, + public readonly conversationId: string, + public readonly recipientIds: string[], + ) {} +} + +export class MessageDeliveredEvent { + constructor( + public readonly messageId: string, + public readonly conversationId: string, + public readonly userId: string, + ) {} +} + +export class MessageReadEvent { + constructor( + public readonly messageId: string, + public readonly conversationId: string, + public readonly userId: string, + ) {} +} + +export class UserPresenceChangedEvent { + constructor( + public readonly userId: string, + public readonly isOnline: boolean, + public readonly lastSeenAt?: Date, + ) {} +} \ No newline at end of file diff --git a/src/messaging/guards/ws-jwt-auth.guard.ts b/src/messaging/guards/ws-jwt-auth.guard.ts new file mode 100644 index 00000000..f6b3775d --- /dev/null +++ b/src/messaging/guards/ws-jwt-auth.guard.ts @@ -0,0 +1,78 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; +import { Observable } from "rxjs"; +import { Socket } from "socket.io"; +import { ConfigService } from "@nestjs/config"; +import * as jwt from "jsonwebtoken"; +import { TokenBlacklistService } from "../../auth/token-blacklist.service"; + +interface JwtPayload { + sub?: string; + address?: string; + email?: string; + username?: string; + role?: string; + jti?: string; + iat?: number; + exp?: number; +} + +@Injectable() +export class WsJwtAuthGuard implements CanActivate { + constructor( + private configService: ConfigService, + private tokenBlacklist: TokenBlacklistService, + ) {} + + canActivate(context: ExecutionContext): boolean | Promise | Observable { + const client: Socket = context.switchToWs().getClient(); + const authToken = this.extractTokenFromHandshake(client); + + if (!authToken) { + throw new UnauthorizedException("Authentication token not provided"); + } + + try { + const secret = this.configService.get("JWT_SECRET"); + const payload = jwt.verify(authToken, secret) as JwtPayload; + + // Check if token is blacklisted + if (payload.jti && this.tokenBlacklist.isRevoked(payload.jti)) { + throw new UnauthorizedException("Token has been revoked"); + } + + // Attach user to client for later use + if (payload.sub) { + client.data.user = { + id: payload.sub, + email: payload.email, + username: payload.username, + role: payload.role || "user", + type: "traditional", + }; + } else if (payload.address) { + client.data.user = { + address: payload.address, + email: payload.email, + role: payload.role || "user", + type: "wallet", + }; + } else { + throw new UnauthorizedException("Invalid token payload"); + } + + return true; + } catch (error) { + throw new UnauthorizedException("Invalid or expired token"); + } + } + + private extractTokenFromHandshake(client: Socket): string | undefined { + const authHeader = client.handshake.auth?.token || client.handshake.headers?.authorization; + if (!authHeader) return undefined; + + if (authHeader.startsWith("Bearer ")) { + return authHeader.substring(7); + } + return authHeader; + } +} \ No newline at end of file diff --git a/src/messaging/messaging.controller.ts b/src/messaging/messaging.controller.ts new file mode 100644 index 00000000..9f38e3a2 --- /dev/null +++ b/src/messaging/messaging.controller.ts @@ -0,0 +1,69 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, +} from "@nestjs/common"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import { StrategyAuthGuard } from "../auth/guards/strategy-auth.guard"; +import { MessagingService } from "./messaging.service"; +import { CreateConversationDto } from "./dto/create-conversation.dto"; +import { SendMessageDto } from "./dto/send-message.dto"; +import { GetMessagesDto } from "./dto/get-messages.dto"; +import { Conversation } from "./entities/conversation.entity"; +import { Message } from "./entities/message.entity"; +import { UserPresence } from "./entities/user-presence.entity"; + +@Controller("messaging") +@UseGuards(StrategyAuthGuard) +export class MessagingController { + constructor(private readonly messagingService: MessagingService) {} + + @Post("conversations") + async createConversation( + @Body() createConversationDto: CreateConversationDto, + @CurrentUser() user: any, + ): Promise { + const userId = user.id || user.address; + return this.messagingService.createConversation(createConversationDto, userId); + } + + @Post("messages") + async sendMessage( + @Body() sendMessageDto: SendMessageDto, + @CurrentUser() user: any, + ): Promise { + const userId = user.id || user.address; + return this.messagingService.sendMessage(sendMessageDto, userId); + } + + @Get("conversations/:conversationId/messages") + async getConversationMessages( + @Param("conversationId") conversationId: string, + @Query() getMessagesDto: GetMessagesDto, + @CurrentUser() user: any, + ): Promise<{ messages: Message[]; hasMore: boolean }> { + const userId = user.id || user.address; + return this.messagingService.getConversationMessages( + conversationId, + userId, + getMessagesDto, + ); + } + + @Get("conversations") + async getUserConversations(@CurrentUser() user: any): Promise { + const userId = user.id || user.address; + return this.messagingService.getUserConversations(userId); + } + + @Get("users/:userId/presence") + async getUserPresence( + @Param("userId") userId: string, + ): Promise { + return this.messagingService.getUserPresence(userId); + } +} \ No newline at end of file diff --git a/src/messaging/messaging.gateway.ts b/src/messaging/messaging.gateway.ts new file mode 100644 index 00000000..915393a0 --- /dev/null +++ b/src/messaging/messaging.gateway.ts @@ -0,0 +1,144 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + ConnectedSocket, + MessageBody, +} from "@nestjs/websockets"; +import { Server, Socket } from "socket.io"; +import { UseGuards } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { WsJwtAuthGuard } from "./guards/ws-jwt-auth.guard"; +import { MessagingService } from "./messaging.service"; +import { + MessageDeliveredEvent, + MessageReadEvent, + UserPresenceChangedEvent, +} from "./events/message.events"; + +@WebSocketGateway({ + cors: { + origin: "*", + }, + namespace: "/messaging", +}) +@UseGuards(WsJwtAuthGuard) +export class MessagingGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + constructor( + private readonly messagingService: MessagingService, + private eventEmitter: EventEmitter2, + ) {} + + async handleConnection(client: Socket) { + const userId = client.data.user?.id || client.data.user?.address; + if (!userId) { + client.disconnect(); + return; + } + + // Join user to their personal room for direct messages + client.join(`user:${userId}`); + + // Update user presence + await this.messagingService.updateUserPresence(userId, true, client.id); + + // Notify relevant users that this user is online + this.eventEmitter.emit( + "user.presence.changed", + new UserPresenceChangedEvent(userId, true), + ); + } + + async handleDisconnect(client: Socket) { + const userId = client.data.user?.id || client.data.user?.address; + if (!userId) return; + + // Update user presence + await this.messagingService.updateUserPresence(userId, false, null); + + // Notify relevant users that this user is offline + this.eventEmitter.emit( + "user.presence.changed", + new UserPresenceChangedEvent(userId, false, new Date()), + ); + } + + @SubscribeMessage("message:delivered") + async handleMessageDelivered( + @ConnectedSocket() client: Socket, + @MessageBody() data: { messageId: string; conversationId: string }, + ) { + const userId = client.data.user?.id || client.data.user?.address; + const { messageId, conversationId } = data; + + await this.messagingService.markMessageAsDelivered(messageId, userId); + + this.eventEmitter.emit( + "message.delivered", + new MessageDeliveredEvent(messageId, conversationId, userId), + ); + + // Broadcast delivery status to conversation participants + this.server.to(`conversation:${conversationId}`).emit("message:delivered", { + messageId, + userId, + deliveredAt: new Date(), + }); + } + + @SubscribeMessage("message:read") + async handleMessageRead( + @ConnectedSocket() client: Socket, + @MessageBody() data: { messageId: string; conversationId: string }, + ) { + const userId = client.data.user?.id || client.data.user?.address; + const { messageId, conversationId } = data; + + await this.messagingService.markMessageAsRead(messageId, userId); + + this.eventEmitter.emit( + "message.read", + new MessageReadEvent(messageId, conversationId, userId), + ); + + // Broadcast read status to conversation participants + this.server.to(`conversation:${conversationId}`).emit("message:read", { + messageId, + userId, + readAt: new Date(), + }); + } + + @SubscribeMessage("conversation:join") + async handleJoinConversation( + @ConnectedSocket() client: Socket, + @MessageBody() data: { conversationId: string }, + ) { + const userId = client.data.user?.id || client.data.user?.address; + const { conversationId } = data; + + // Verify user is part of the conversation + const isParticipant = await this.messagingService.isUserInConversation( + conversationId, + userId, + ); + + if (isParticipant) { + client.join(`conversation:${conversationId}`); + } + } + + @SubscribeMessage("conversation:leave") + async handleLeaveConversation( + @ConnectedSocket() client: Socket, + @MessageBody() data: { conversationId: string }, + ) { + const { conversationId } = data; + client.leave(`conversation:${conversationId}`); + } +} \ No newline at end of file diff --git a/src/messaging/messaging.module.ts b/src/messaging/messaging.module.ts new file mode 100644 index 00000000..c3a8aea0 --- /dev/null +++ b/src/messaging/messaging.module.ts @@ -0,0 +1,28 @@ +import { Module, OnModuleInit } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { MessagingGateway } from "./messaging.gateway"; +import { MessagingService } from "./messaging.service"; +import { MessagingController } from "./messaging.controller"; +import { Conversation } from "./entities/conversation.entity"; +import { Message } from "./entities/message.entity"; +import { UserPresence } from "./entities/user-presence.entity"; +import { User } from "../user/entities/user.entity"; +import { WsJwtAuthGuard } from "./guards/ws-jwt-auth.guard"; +import { TokenBlacklistService } from "../auth/token-blacklist.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Conversation, Message, UserPresence, User]), + ], + providers: [MessagingGateway, MessagingService, WsJwtAuthGuard, TokenBlacklistService], + controllers: [MessagingController], + exports: [MessagingService], +}) +export class MessagingModule implements OnModuleInit { + constructor(private readonly messagingService: MessagingService, private readonly gateway: MessagingGateway) {} + + onModuleInit() { + // Pass the socket.io server instance to the messaging service + this.messagingService.setSocketServer(this.gateway.server); + } +} \ No newline at end of file diff --git a/src/messaging/messaging.service.spec.ts b/src/messaging/messaging.service.spec.ts new file mode 100644 index 00000000..bc8f6eeb --- /dev/null +++ b/src/messaging/messaging.service.spec.ts @@ -0,0 +1,181 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { Repository } from "typeorm"; +import { MessagingService } from "./messaging.service"; +import { Conversation } from "./entities/conversation.entity"; +import { Message } from "./entities/message.entity"; +import { UserPresence } from "./entities/user-presence.entity"; +import { User } from "../user/entities/user.entity"; +import { ConversationType, MessageStatus } from "./entities/message.enum"; +import { CreateConversationDto } from "./dto/create-conversation.dto"; + +const mockUser = { + id: "user-123", + username: "testuser", + email: "test@example.com", +}; + +const mockUser2 = { + id: "user-456", + username: "testuser2", + email: "test2@example.com", +}; + +describe("MessagingService", () => { + let service: MessagingService; + let conversationRepository: Repository; + let messageRepository: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MessagingService, + EventEmitter2, + { + provide: getRepositoryToken(Conversation), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + having: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + })), + }, + }, + { + provide: getRepositoryToken(Message), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(UserPresence), + useValue: { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User), + useValue: { + findBy: jest.fn().mockResolvedValue([mockUser, mockUser2]), + findOneBy: jest.fn().mockResolvedValue(mockUser), + }, + }, + ], + }).compile(); + + service = module.get(MessagingService); + conversationRepository = module.get>(getRepositoryToken(Conversation)); + messageRepository = module.get>(getRepositoryToken(Message)); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + it("should create a conversation", async () => { + const createDto: CreateConversationDto = { + participantIds: ["user-456"], + type: ConversationType.PRIVATE, + }; + + const mockConversation = { + id: "conv-123", + participants: [mockUser, mockUser2], + type: ConversationType.PRIVATE, + }; + + jest.spyOn(conversationRepository, "create").mockReturnValue(mockConversation as any); + jest.spyOn(conversationRepository, "save").mockResolvedValue(mockConversation as any); + + const result = await service.createConversation(createDto, "user-123"); + expect(result).toEqual(mockConversation); + expect(conversationRepository.create).toHaveBeenCalled(); + }); + + it("should send a message with correct status", async () => { + const mockConversation = { + id: "conv-123", + participants: [mockUser, mockUser2], + }; + + const mockMessage = { + id: "msg-123", + content: "Hello world", + sender: mockUser, + conversation: mockConversation, + status: MessageStatus.SENT, + createdAt: new Date(), + updatedAt: new Date(), + }; + + jest.spyOn(conversationRepository, "findOne").mockResolvedValue(mockConversation as any); + jest.spyOn(messageRepository, "create").mockReturnValue(mockMessage as any); + jest.spyOn(messageRepository, "save").mockResolvedValue(mockMessage as any); + + const result = await service.sendMessage( + { conversationId: "conv-123", content: "Hello world" }, + "user-123" + ); + + expect(result.status).toBe(MessageStatus.SENT); + expect(result.content).toBe("Hello world"); + }); + + it("should mark message as delivered", async () => { + const mockConversation = { + id: "conv-123", + participants: [mockUser, mockUser2], + }; + + const mockMessage = { + id: "msg-123", + content: "Hello world", + sender: mockUser, + conversation: mockConversation, + status: MessageStatus.SENT, + save: jest.fn(), + }; + + jest.spyOn(messageRepository, "findOne").mockResolvedValue(mockMessage as any); + jest.spyOn(messageRepository, "save").mockImplementation((msg) => Promise.resolve(msg as any)); + + const result = await service.markMessageAsDelivered("msg-123", "user-456"); + expect(result.status).toBe(MessageStatus.DELIVERED); + expect(result.deliveredAt).toBeDefined(); + }); + + it("should mark message as read", async () => { + const mockConversation = { + id: "conv-123", + participants: [mockUser, mockUser2], + }; + + const mockMessage = { + id: "msg-123", + content: "Hello world", + sender: mockUser, + conversation: mockConversation, + status: MessageStatus.DELIVERED, + deliveredAt: new Date(), + save: jest.fn(), + }; + + jest.spyOn(messageRepository, "findOne").mockResolvedValue(mockMessage as any); + jest.spyOn(messageRepository, "save").mockImplementation((msg) => Promise.resolve(msg as any)); + + const result = await service.markMessageAsRead("msg-123", "user-456"); + expect(result.status).toBe(MessageStatus.READ); + expect(result.readAt).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/src/messaging/messaging.service.ts b/src/messaging/messaging.service.ts new file mode 100644 index 00000000..ed1d141b --- /dev/null +++ b/src/messaging/messaging.service.ts @@ -0,0 +1,328 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, + BadRequestException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository, LessThan, In } from "typeorm"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { Conversation } from "./entities/conversation.entity"; +import { Message } from "./entities/message.entity"; +import { UserPresence } from "./entities/user-presence.entity"; +import { User } from "../user/entities/user.entity"; +import { MessageStatus, ConversationType } from "./entities/message.enum"; +import { CreateConversationDto } from "./dto/create-conversation.dto"; +import { SendMessageDto } from "./dto/send-message.dto"; +import { GetMessagesDto } from "./dto/get-messages.dto"; +import { MessageSentEvent } from "./events/message.events"; +import { Server } from "socket.io"; + +@Injectable() +export class MessagingService { + constructor( + @InjectRepository(Conversation) + private readonly conversationRepository: Repository, + @InjectRepository(Message) + private readonly messageRepository: Repository, + @InjectRepository(UserPresence) + private readonly userPresenceRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private eventEmitter: EventEmitter2, + ) {} + + setSocketServer(server: Server) { + this.server = server; + } + private server: Server; + + async createConversation( + createConversationDto: CreateConversationDto, + creatorId: string, + ): Promise { + // Add creator to participants if not already included + const participantIds = [...new Set([...createConversationDto.participantIds, creatorId])]; + + // Validate all participants exist + const participants = await this.userRepository.findBy({ + id: In(participantIds), + }); + + if (participants.length !== participantIds.length) { + throw new BadRequestException("One or more participants do not exist"); + } + + // For private conversations, check if one already exists between these users + if (createConversationDto.type === ConversationType.PRIVATE && participants.length === 2) { + const existingConversation = await this.conversationRepository + .createQueryBuilder("conversation") + .leftJoinAndSelect("conversation.participants", "participant") + .where("conversation.type = :type", { type: ConversationType.PRIVATE }) + .andWhere("participant.id IN (:...ids)", { ids: participantIds }) + .groupBy("conversation.id") + .having("COUNT(DISTINCT participant.id) = :count", { count: 2 }) + .getOne(); + + if (existingConversation) { + return existingConversation; + } + } + + const conversation = this.conversationRepository.create({ + ...createConversationDto, + participants, + }); + + return this.conversationRepository.save(conversation); + } + + async sendMessage( + sendMessageDto: SendMessageDto, + senderId: string, + ): Promise { + const conversation = await this.conversationRepository.findOne({ + where: { id: sendMessageDto.conversationId }, + relations: ["participants"], + }); + + if (!conversation) { + throw new NotFoundException("Conversation not found"); + } + + // Verify sender is in conversation + const isSenderParticipant = conversation.participants.some( + (p) => p.id === senderId, + ); + if (!isSenderParticipant) { + throw new ForbiddenException("You are not a participant in this conversation"); + } + + const sender = await this.userRepository.findOneBy({ id: senderId }); + if (!sender) { + throw new NotFoundException("Sender not found"); + } + + const message = this.messageRepository.create({ + content: sendMessageDto.content, + metadata: sendMessageDto.metadata, + sender, + conversation, + status: MessageStatus.SENT, + }); + + const savedMessage = await this.messageRepository.save(message); + + // Update conversation's lastMessageAt + conversation.lastMessageAt = new Date(); + await this.conversationRepository.save(conversation); + + // Get recipient IDs (all participants except sender) + const recipientIds = conversation.participants + .filter((p) => p.id !== senderId) + .map((p) => p.id); + + // Emit event + this.eventEmitter.emit( + "message.sent", + new MessageSentEvent(savedMessage, conversation.id, recipientIds), + ); + + // Send to all users in the conversation room + if (this.server) { + this.server.to(`conversation:${conversation.id}`).emit("message:new", { + ...savedMessage, + sender: { id: sender.id, username: sender.username }, + }); + + // Also send to each recipient's personal room if they're not in the conversation room + for (const recipientId of recipientIds) { + this.server.to(`user:${recipientId}`).emit("message:notification", { + message: savedMessage, + conversationId: conversation.id, + }); + } + } + + return savedMessage; + } + + async getConversationMessages( + conversationId: string, + userId: string, + getMessagesDto: GetMessagesDto, + ): Promise<{ messages: Message[]; hasMore: boolean }> { + const conversation = await this.conversationRepository.findOne({ + where: { id: conversationId }, + relations: ["participants"], + }); + + if (!conversation) { + throw new NotFoundException("Conversation not found"); + } + + // Verify user is in conversation + const isParticipant = conversation.participants.some((p) => p.id === userId); + if (!isParticipant) { + throw new ForbiddenException("You are not a participant in this conversation"); + } + + const query = this.messageRepository + .createQueryBuilder("message") + .leftJoinAndSelect("message.sender", "sender") + .where("message.conversation.id = :conversationId", { conversationId }) + .orderBy("message.createdAt", "DESC") + .limit(getMessagesDto.limit + 1); + + if (getMessagesDto.before) { + const beforeMessage = await this.messageRepository.findOneBy({ + id: getMessagesDto.before, + }); + if (beforeMessage) { + query.andWhere("message.createdAt < :beforeDate", { + beforeDate: beforeMessage.createdAt, + }); + } + } + + const messages = await query.getMany(); + const hasMore = messages.length > getMessagesDto.limit; + const limitedMessages = messages.slice(0, getMessagesDto.limit); + + return { messages: limitedMessages, hasMore }; + } + + async getUserConversations(userId: string): Promise { + return this.conversationRepository + .createQueryBuilder("conversation") + .leftJoinAndSelect("conversation.participants", "participant") + .leftJoinAndSelect("conversation.messages", "messages", "messages.createdAt = (SELECT MAX(m.createdAt) FROM message m WHERE m.conversation.id = conversation.id)") + .leftJoinAndSelect("messages.sender", "lastMessageSender") + .where("participant.id = :userId", { userId }) + .orderBy("conversation.lastMessageAt", "DESC") + .getMany(); + } + + async markMessageAsDelivered(messageId: string, userId: string): Promise { + const message = await this.messageRepository.findOne({ + where: { id: messageId }, + relations: ["conversation", "conversation.participants"], + }); + + if (!message) { + throw new NotFoundException("Message not found"); + } + + // Verify user is in conversation + const isParticipant = message.conversation.participants.some( + (p) => p.id === userId, + ); + if (!isParticipant) { + throw new ForbiddenException("You are not a participant in this conversation"); + } + + if (message.status === MessageStatus.SENT) { + message.status = MessageStatus.DELIVERED; + message.deliveredAt = new Date(); + return this.messageRepository.save(message); + } + + return message; + } + + async markMessageAsRead(messageId: string, userId: string): Promise { + const message = await this.messageRepository.findOne({ + where: { id: messageId }, + relations: ["conversation", "conversation.participants"], + }); + + if (!message) { + throw new NotFoundException("Message not found"); + } + + // Verify user is in conversation + const isParticipant = message.conversation.participants.some( + (p) => p.id === userId, + ); + if (!isParticipant) { + throw new ForbiddenException("You are not a participant in this conversation"); + } + + if (message.status !== MessageStatus.READ) { + message.status = MessageStatus.READ; + message.readAt = new Date(); + return this.messageRepository.save(message); + } + + return message; + } + + async updateUserPresence( + userId: string, + isOnline: boolean, + socketId: string | null, + ): Promise { + let userPresence = await this.userPresenceRepository.findOne({ + where: { user: { id: userId } }, + }); + + if (!userPresence) { + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) { + throw new NotFoundException("User not found"); + } + userPresence = this.userPresenceRepository.create({ user }); + } + + userPresence.isOnline = isOnline; + userPresence.currentSocketId = socketId; + if (!isOnline) { + userPresence.lastSeenAt = new Date(); + } + + return this.userPresenceRepository.save(userPresence); + } + + async getUserPresence(userId: string): Promise { + const presence = await this.userPresenceRepository.findOne({ + where: { user: { id: userId } }, + }); + if (!presence) { + return { + id: "", + user: null, + isOnline: false, + lastSeenAt: null, + currentSocketId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + } + return presence; + } + + async isUserInConversation(conversationId: string, userId: string): Promise { + const conversation = await this.conversationRepository.findOne({ + where: { id: conversationId }, + relations: ["participants"], + }); + if (!conversation) return false; + return conversation.participants.some((p) => p.id === userId); + } + + // Archive messages older than 1 year (cleanup strategy) + async archiveOldMessages(): Promise { + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + + const oldMessages = await this.messageRepository.find({ + where: { + createdAt: LessThan(oneYearAgo), + }, + }); + + // In a real implementation, you would move these to an archive table or storage + // For now, we'll just count them - you could implement soft delete or move to cold storage + return oldMessages.length; + } +} \ No newline at end of file