diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index cef8972..fc16f96 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -77,7 +77,6 @@ export class ChatGateway // tell everyone else someone joined client.broadcast.emit('serverNotice', `[+] ${payload.username} joined`); - } catch (err) { // st-4: Disconnect unauthorized clients this.logger.warn(`[!] Rejected connection: ${err}`); @@ -104,46 +103,44 @@ export class ChatGateway @MessageBody() body: JoinLeaveRoomDto, //roomId @ConnectedSocket() client: Socket, ) { - // extract user - const username=client.data.user.username; + const username = client.data.user.username; // join user in the room(front-end will provide roomId(standard appraoch)) - client.join(body.roomId) + client.join(body.roomId); // tell the joiner he successfully joined - client.emit("roomJoined",{ - roomId:body.roomId, - message:`You joined room: ${body.roomId}`, - }) + client.emit('roomJoined', { + roomId: body.roomId, + message: `You joined room: ${body.roomId}`, + }); // also tell others in the room except the new joiner - client.to(body.roomId).emit("roomNotice",{ + client.to(body.roomId).emit('roomNotice', { roomId: body.roomId, message: `[+] ${username} joined the room`, - }) + }); // return acknowledgement return { success: true, roomId: body.roomId }; - } @UseGuards(WsJwtGuard) @SubscribeMessage('leaveRoom') handleLeaveRoom( - @MessageBody() body: JoinLeaveRoomDto, //roomId + @MessageBody() body: JoinLeaveRoomDto, //roomId @ConnectedSocket() client: Socket, ) { // extract user - const username=client.data.user.username; + const username = client.data.user.username; // remove the user from the room - client.leave(body.roomId) + client.leave(body.roomId); // confirm to the leaver client.emit('roomLeft', { roomId: body.roomId }); - // Notify remaining memebers only + // Notify remaining memebers only client.to(body.roomId).emit('roomNotice', { roomId: body.roomId, message: `[-] ${username} left the room`, @@ -154,31 +151,24 @@ export class ChatGateway @UseGuards(WsJwtGuard) @SubscribeMessage('roomMessage') - handleRoomMessage( - @MessageBody() body:RoomMessageDto, //text:string - @ConnectedSocket() sender:Socket - ){ - // extract user - const username=sender.data.user.username - // Check if user is in room - const isMember = sender.rooms.has(body.roomId); - if (!isMember) { - sender.emit('roomError', { - message: 'You are not allowed', - }); - return; - } - - this.logger.log(`[ROOM:${body.roomId}] ${username}: ${body.text}`); + async handleRoomMessage( + @MessageBody() body: RoomMessageDto, //text:string + @ConnectedSocket() sender: Socket, + ) { + //verify memebership, store chat and return event's data + const data = await this.chatService.handleRoomMessage(body, sender); // send everone in the room(including the sender) - this.server.to(body.roomId).emit('roomMessage', { - roomId: body.roomId, - senderId: sender.id, - username, - text: body.text, - }); - + this.server.to(body.roomId).emit('roomMessage', data); } + @UseGuards(WsJwtGuard) + @SubscribeMessage('startP2P') + async handleStartP2P( + @MessageBody() body: { targetUserId: string }, + @ConnectedSocket() client: Socket, + ) { + + return this.chatService.handleStartP2P(body.targetUserId, client); + } } diff --git a/src/chat/chat.module.ts b/src/chat/chat.module.ts index 93bd78b..46719fe 100644 --- a/src/chat/chat.module.ts +++ b/src/chat/chat.module.ts @@ -3,9 +3,11 @@ import { ChatService } from './chat.service'; import { ChatGateway } from './chat.gateway'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { MessagesModule } from 'src/messages/messages.module'; @Module({ imports: [ + MessagesModule, JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index dfe1c2b..ad76712 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -1,6 +1,74 @@ +import { ConversationsService } from './../conversations/conversations.service'; +import { MessagesService } from './../messages/messages.service'; import { Injectable } from '@nestjs/common'; +import { RoomMessageDto } from './dto/room-message.dto'; +import { Socket } from 'socket.io'; +import { ConversationType } from 'src/conversations/schemas/conversation.schema'; @Injectable() export class ChatService { - + constructor(private readonly messagesService:MessagesService, + private readonly conversationsService:ConversationsService + ){} + + async handleRoomMessage(body:RoomMessageDto, sender:Socket){ + // extract user + const { id: userId, username } = sender.data.user; + // Check if user is in room + const isMember = sender.rooms.has(body.roomId); + if (!isMember) { + sender.emit('roomError', { + message: 'You are not allowed', + }); + return; + } + // store the message + const message=await this.messagesService.create({ + conversationId:body.roomId, + senderId:userId, + text:body.text, + }) + + return{ + roomId:body.roomId, + senderId:userId, + username, + text:body.text, + createdAt:message.createdAt, + } + } + + async handleStartP2P(targetUserId: string, client: Socket) { + const senderId = client.data.user.id; + + if (senderId === targetUserId) { + client.emit('roomError', { message: 'Cannot start a conversation with yourself' }); + return; + } + + // Find existing or create new P2P conversation + let conversation:any = await this.conversationsService.findP2p(senderId, targetUserId) + + if (!conversation) { + conversation = await this.conversationsService.create({ + type: ConversationType.P2P, + participants: [senderId, targetUserId], + }); + } + + const roomId = conversation._id.toString(); + + // Caller joins immediately + client.join(roomId); + + client.emit('p2pReady', { + roomId, // client uses this for all future roomMessage events + targetUserId, + message: `P2P room ready`, + }); + + return { roomId }; + } + + } diff --git a/src/conversations/conversations.service.ts b/src/conversations/conversations.service.ts index 9ddc013..d687cf0 100644 --- a/src/conversations/conversations.service.ts +++ b/src/conversations/conversations.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { CreateConversationDto } from './dto/create-conversation.dto'; import { UpdateConversationDto } from './dto/update-conversation.dto'; -import { Conversation, ConversationDocument } from './schemas/conversation.schema'; +import { Conversation, ConversationDocument, ConversationType } from './schemas/conversation.schema'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; @@ -9,16 +9,23 @@ import { Model } from 'mongoose'; export class ConversationsService { constructor( - @InjectModel(Conversation.name) private userModel: Model, + @InjectModel(Conversation.name) private conversationModel: Model, ) {} - create(createConversationDto: CreateConversationDto) { - return 'This action adds a new conversation'; + async create(createConversationDto: CreateConversationDto):Promise { + return await this.conversationModel.create(createConversationDto) } findAll() { return `This action returns all conversations`; } + + async findP2p(senderId:string, targetUserId:string){ + return await this.conversationModel.findOne({ + type: ConversationType.P2P, + participants: { $all: [senderId, targetUserId], $size: 2 }, + }); + } findOne(id: number) { return `This action returns a #${id} conversation`; diff --git a/src/conversations/dto/create-conversation.dto.ts b/src/conversations/dto/create-conversation.dto.ts index 8b3bd5d..bc62195 100644 --- a/src/conversations/dto/create-conversation.dto.ts +++ b/src/conversations/dto/create-conversation.dto.ts @@ -1 +1,47 @@ -export class CreateConversationDto {} +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ConversationType } from '../schemas/conversation.schema'; +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + ArrayMinSize, + ArrayUnique, + IsArray, +} from 'class-validator'; + +export class CreateConversationDto { + @ApiProperty({ + enum: ConversationType, + enumName: 'ConversationType', + example: ConversationType.P2P, + description: 'Type of the conversation', + }) + @IsEnum(ConversationType) + @IsNotEmpty() + type!: ConversationType; + + @ApiProperty({ + type: [String], + example: ['userId1', 'userId2'], + description: 'List of participant user IDs', + minItems: 2, + }) + @IsArray() + @ArrayMinSize(2) + @ArrayUnique() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + participants!: string[]; + + @ApiPropertyOptional({ + type: String, + example: 'Team Chat', + description: 'Name of the conversation (required for group, omit for p2p)', + nullable: true, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; +} \ No newline at end of file diff --git a/src/conversations/schemas/conversation.schema.ts b/src/conversations/schemas/conversation.schema.ts index 2705fb8..faaa915 100644 --- a/src/conversations/schemas/conversation.schema.ts +++ b/src/conversations/schemas/conversation.schema.ts @@ -1,7 +1,8 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Document } from "mongoose"; export type ConversationDocument = Conversation & Document; - export enum ConversationType{ +export enum ConversationType{ P2P='p2p', GROUP='group' } @@ -19,9 +20,9 @@ export class Conversation { participants!:string[] @Prop({required:false,default:null}) - name!:string // for p2p no name + name?:string // for p2p no name - createdAt!:Date + createdAt!:Date // no prop } export const ConversationSchema = SchemaFactory.createForClass(Conversation); diff --git a/src/messages/dto/create-message.dto.ts b/src/messages/dto/create-message.dto.ts index 6ae798f..81f4f8a 100644 --- a/src/messages/dto/create-message.dto.ts +++ b/src/messages/dto/create-message.dto.ts @@ -1 +1,19 @@ -export class CreateMessageDto {} +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateMessageDto { + @ApiProperty({ example: 'qwertyj', description: 'mongodb comnversation id' }) + @IsNotEmpty() + @IsString() + conversationId!: string; + + @ApiProperty({ example: 'qwertyj', description: 'mongodb user id' }) + @IsNotEmpty() + @IsString() + senderId!: string; + + @ApiProperty({ example: 'Hello user', description: 'user message' }) + @IsNotEmpty() + @IsString() + text!: string; +} diff --git a/src/messages/messages.service.ts b/src/messages/messages.service.ts index 71d9066..c519281 100644 --- a/src/messages/messages.service.ts +++ b/src/messages/messages.service.ts @@ -7,9 +7,11 @@ import { Model } from 'mongoose'; @Injectable() export class MessagesService { - constructor( @InjectModel(Message.name) private MessageModel: Model,){} - create(createMessageDto: CreateMessageDto) { - return 'This action adds a new message'; + constructor( + @InjectModel(Message.name) private MessageModel: Model, + ) {} + async create(createMessageDto: CreateMessageDto): Promise { + return await this.MessageModel.create(createMessageDto); } findAll() { diff --git a/src/messages/schemas/message.schema.ts b/src/messages/schemas/message.schema.ts index e00a31c..5a3add6 100644 --- a/src/messages/schemas/message.schema.ts +++ b/src/messages/schemas/message.schema.ts @@ -1,19 +1,21 @@ -import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; export type MessageDocument = Message & Document; -@Schema({timestamps:true}) +@Schema({ timestamps: true }) export class Message { - @Prop({ required:true}) - conversationId!:string + @Prop({ required: true }) + conversationId!: string; - @Prop({ required:true}) - senderId!:string + @Prop({ required: true }) + senderId!: string; - @Prop({ required:false}) - text!:string + @Prop({ required: false }) + text!: string; - createdAt!:Date + // no prop + createdAt!:Date } export const MessageSchema = SchemaFactory.createForClass(Message);