From 399b8aa937212da8cb62a68ef50767bf71a0c3fa Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Thu, 5 Mar 2026 06:37:07 +0530 Subject: [PATCH] feat: implement support chat functionality with new module, service, controller, DTOs, and schema updates. --- prisma/schema.prisma | 45 ++++ src/app.module.ts | 2 + src/messages/messages.gateway.ts | 99 ++++++++ src/messages/messages.module.ts | 2 + src/support-chat/dto/index.ts | 1 + .../dto/send-support-message.dto.ts | 10 + src/support-chat/index.ts | 3 + src/support-chat/support-chat.controller.ts | 137 +++++++++++ src/support-chat/support-chat.module.ts | 12 + src/support-chat/support-chat.service.ts | 219 ++++++++++++++++++ 10 files changed, 530 insertions(+) create mode 100644 src/support-chat/dto/index.ts create mode 100644 src/support-chat/dto/send-support-message.dto.ts create mode 100644 src/support-chat/index.ts create mode 100644 src/support-chat/support-chat.controller.ts create mode 100644 src/support-chat/support-chat.module.ts create mode 100644 src/support-chat/support-chat.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4841026..b72fa3d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -75,6 +75,11 @@ enum MessageStatus { READ } +enum SupportChatStatus { + OPEN + CLOSED +} + // =========================================== // USER - Authentication Only @@ -135,6 +140,9 @@ model User { // Notifications notifications Notification[] + // Support Chat + supportChats SupportChat[] + @@index([email]) @@index([role]) @@index([status]) @@ -572,3 +580,40 @@ model Testimonial { @@index([agentProfileId]) @@map("testimonials") } + +// =========================================== +// SUPPORT CHAT SYSTEM +// =========================================== + +model SupportChat { + id String @id @default(uuid()) + userId String + status SupportChatStatus @default(OPEN) + lastMessageAt DateTime? + lastMessageText String? @db.VarChar(255) + userUnreadCount Int @default(0) + adminUnreadCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + messages SupportMessage[] + + @@index([userId]) + @@index([status]) + @@map("support_chats") +} + +model SupportMessage { + id String @id @default(uuid()) + chatId String + senderId String + senderRole String // "USER" or "ADMIN" + content String @db.Text + createdAt DateTime @default(now()) + + chat SupportChat @relation(fields: [chatId], references: [id], onDelete: Cascade) + + @@index([chatId]) + @@map("support_messages") +} diff --git a/src/app.module.ts b/src/app.module.ts index 1db696e..28cded2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { MessagesModule } from './messages'; import { CmsModule } from './cms'; import { NotificationsModule } from './notifications'; import { TestimonialsModule } from './testimonials'; +import { SupportChatModule } from './support-chat'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -61,6 +62,7 @@ import { AppService } from './app.service'; CmsModule, NotificationsModule, TestimonialsModule, + SupportChatModule, ], controllers: [AppController], providers: [ diff --git a/src/messages/messages.gateway.ts b/src/messages/messages.gateway.ts index 265015f..d2086cb 100644 --- a/src/messages/messages.gateway.ts +++ b/src/messages/messages.gateway.ts @@ -11,6 +11,7 @@ import { Server, Socket } from 'socket.io'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { MessagesService } from './messages.service'; +import { SupportChatService } from '../support-chat/support-chat.service'; import { CreateMessageDto } from './dto'; import { Logger } from '@nestjs/common'; @@ -35,6 +36,7 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect constructor( private readonly messagesService: MessagesService, + private readonly supportChatService: SupportChatService, private readonly jwtService: JwtService, private readonly configService: ConfigService, ) {} @@ -266,6 +268,103 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect } } + // ========================================== + // SUPPORT CHAT EVENTS + // ========================================== + + /** + * Join a support chat room + */ + @SubscribeMessage('support_join') + async handleSupportJoin( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() data: { chatId: string }, + ) { + if (!client.userId) { + return { error: 'Unauthorized' }; + } + + try { + const isAdmin = client.userRole === 'ADMIN'; + await this.supportChatService.getChat(data.chatId, client.userId, isAdmin); + client.join(`support:${data.chatId}`); + this.logger.log(`User ${client.userId} joined support chat ${data.chatId}`); + return { success: true }; + } catch (error) { + return { error: error.message }; + } + } + + /** + * Leave a support chat room + */ + @SubscribeMessage('support_leave') + handleSupportLeave( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() data: { chatId: string }, + ) { + client.leave(`support:${data.chatId}`); + this.logger.log(`User ${client.userId} left support chat ${data.chatId}`); + return { success: true }; + } + + /** + * Send a message in a support chat via WebSocket + */ + @SubscribeMessage('support_send_message') + async handleSupportSendMessage( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() data: { chatId: string; content: string }, + ) { + if (!client.userId) { + return { error: 'Unauthorized' }; + } + + try { + const senderRole = client.userRole === 'ADMIN' ? 'ADMIN' : 'USER'; + const message = await this.supportChatService.sendMessage( + data.chatId, + client.userId, + senderRole, + data.content, + ); + + // Broadcast to all clients in the support chat room + this.server.to(`support:${data.chatId}`).emit('support_new_message', message); + + return { success: true, message }; + } catch (error) { + return { error: error.message }; + } + } + + /** + * Support chat typing indicators + */ + @SubscribeMessage('support_typing_start') + handleSupportTypingStart( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() data: { chatId: string }, + ) { + if (!client.userId) return; + client.to(`support:${data.chatId}`).emit('support_typing_start', { + userId: client.userId, + chatId: data.chatId, + }); + } + + @SubscribeMessage('support_typing_stop') + handleSupportTypingStop( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() data: { chatId: string }, + ) { + if (!client.userId) return; + client.to(`support:${data.chatId}`).emit('support_typing_stop', { + userId: client.userId, + chatId: data.chatId, + }); + } + /** * Broadcast user online/offline status */ diff --git a/src/messages/messages.module.ts b/src/messages/messages.module.ts index 05a94be..6956b33 100644 --- a/src/messages/messages.module.ts +++ b/src/messages/messages.module.ts @@ -5,10 +5,12 @@ import { MessagesController } from './messages.controller'; import { MessagesService } from './messages.service'; import { MessagesGateway } from './messages.gateway'; import { PrismaModule } from '../prisma/prisma.module'; +import { SupportChatModule } from '../support-chat/support-chat.module'; @Module({ imports: [ PrismaModule, + SupportChatModule, ConfigModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/src/support-chat/dto/index.ts b/src/support-chat/dto/index.ts new file mode 100644 index 0000000..df9b199 --- /dev/null +++ b/src/support-chat/dto/index.ts @@ -0,0 +1 @@ +export { SendSupportMessageDto } from './send-support-message.dto'; diff --git a/src/support-chat/dto/send-support-message.dto.ts b/src/support-chat/dto/send-support-message.dto.ts new file mode 100644 index 0000000..59e370e --- /dev/null +++ b/src/support-chat/dto/send-support-message.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MaxLength, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SendSupportMessageDto { + @ApiProperty({ description: 'Message content', maxLength: 2000 }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + content: string; +} diff --git a/src/support-chat/index.ts b/src/support-chat/index.ts new file mode 100644 index 0000000..099b2e2 --- /dev/null +++ b/src/support-chat/index.ts @@ -0,0 +1,3 @@ +export { SupportChatModule } from './support-chat.module'; +export { SupportChatService } from './support-chat.service'; +export { SupportChatController } from './support-chat.controller'; diff --git a/src/support-chat/support-chat.controller.ts b/src/support-chat/support-chat.controller.ts new file mode 100644 index 0000000..cb75e2c --- /dev/null +++ b/src/support-chat/support-chat.controller.ts @@ -0,0 +1,137 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, + ApiParam, +} from '@nestjs/swagger'; +import { SupportChatService } from './support-chat.service'; +import { SendSupportMessageDto } from './dto'; +import { JwtAuthGuard } from '../auth/guards'; +import { CurrentUser } from '../auth/decorators'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole, SupportChatStatus } from '@prisma/client'; + +@ApiTags('Support Chat') +@Controller('support-chat') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth('JWT-auth') +export class SupportChatController { + constructor(private readonly supportChatService: SupportChatService) {} + + // ========================================== + // GET OR CREATE SUPPORT CHAT (User/Agent) + // ========================================== + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get or create a support chat for the current user' }) + async getOrCreateChat(@CurrentUser('id') userId: string) { + return this.supportChatService.getOrCreateChat(userId); + } + + // ========================================== + // GET MY SUPPORT CHAT (User/Agent) + // ========================================== + @Get('my') + @ApiOperation({ summary: 'Get current user\'s open support chat' }) + async getMyChat(@CurrentUser('id') userId: string) { + return this.supportChatService.getOrCreateChat(userId); + } + + // ========================================== + // GET MESSAGES + // ========================================== + @Get(':id/messages') + @ApiOperation({ summary: 'Get messages for a support chat' }) + @ApiParam({ name: 'id', description: 'Support Chat ID' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getMessages( + @Param('id') chatId: string, + @CurrentUser('id') userId: string, + @CurrentUser('role') role: UserRole, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + // Validate access + const isAdmin = role === UserRole.ADMIN; + await this.supportChatService.getChat(chatId, userId, isAdmin); + + return this.supportChatService.getMessages( + chatId, + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 50, + ); + } + + // ========================================== + // SEND MESSAGE (REST fallback) + // ========================================== + @Post(':id/messages') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Send a message in a support chat' }) + @ApiParam({ name: 'id', description: 'Support Chat ID' }) + async sendMessage( + @Param('id') chatId: string, + @CurrentUser('id') userId: string, + @CurrentUser('role') role: UserRole, + @Body() dto: SendSupportMessageDto, + ) { + // Validate access + const isAdmin = role === UserRole.ADMIN; + await this.supportChatService.getChat(chatId, userId, isAdmin); + + const senderRole = role === UserRole.ADMIN ? 'ADMIN' : 'USER'; + return this.supportChatService.sendMessage(chatId, userId, senderRole, dto.content); + } + + // ========================================== + // MARK AS READ + // ========================================== + @Patch(':id/read') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Mark support chat messages as read' }) + @ApiParam({ name: 'id', description: 'Support Chat ID' }) + async markAsRead( + @Param('id') chatId: string, + @CurrentUser('role') role: UserRole, + ) { + return this.supportChatService.markAsRead(chatId, role); + } + + // ========================================== + // ADMIN: GET ALL SUPPORT CHATS + // ========================================== + @Get('admin/all') + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Admin: Get all support chats' }) + @ApiQuery({ name: 'status', required: false, enum: SupportChatStatus }) + async getAllChats(@Query('status') status?: SupportChatStatus) { + return this.supportChatService.getAllChats(status); + } + + // ========================================== + // ADMIN: CLOSE SUPPORT CHAT + // ========================================== + @Patch('admin/:id/close') + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Admin: Close a support chat' }) + @ApiParam({ name: 'id', description: 'Support Chat ID' }) + async closeChat(@Param('id') chatId: string) { + return this.supportChatService.closeChat(chatId); + } +} diff --git a/src/support-chat/support-chat.module.ts b/src/support-chat/support-chat.module.ts new file mode 100644 index 0000000..e0efe48 --- /dev/null +++ b/src/support-chat/support-chat.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SupportChatController } from './support-chat.controller'; +import { SupportChatService } from './support-chat.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [SupportChatController], + providers: [SupportChatService], + exports: [SupportChatService], +}) +export class SupportChatModule {} diff --git a/src/support-chat/support-chat.service.ts b/src/support-chat/support-chat.service.ts new file mode 100644 index 0000000..e4beff0 --- /dev/null +++ b/src/support-chat/support-chat.service.ts @@ -0,0 +1,219 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SupportChatStatus } from '@prisma/client'; + +@Injectable() +export class SupportChatService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Get or create an OPEN support chat for a user + */ + async getOrCreateChat(userId: string) { + // Find existing OPEN chat + let chat = await this.prisma.supportChat.findFirst({ + where: { userId, status: SupportChatStatus.OPEN }, + include: { + user: { + select: { + id: true, + email: true, + userProfile: { + select: { firstName: true, lastName: true, avatar: true }, + }, + agentProfile: { + select: { firstName: true, lastName: true, avatar: true }, + }, + }, + }, + }, + }); + + if (!chat) { + chat = await this.prisma.supportChat.create({ + data: { userId }, + include: { + user: { + select: { + id: true, + email: true, + userProfile: { + select: { firstName: true, lastName: true, avatar: true }, + }, + agentProfile: { + select: { firstName: true, lastName: true, avatar: true }, + }, + }, + }, + }, + }); + } + + return chat; + } + + /** + * Get a chat by ID with access validation + */ + async getChat(chatId: string, userId?: string, isAdmin = false) { + const chat = await this.prisma.supportChat.findUnique({ + where: { id: chatId }, + include: { + user: { + select: { + id: true, + email: true, + userProfile: { + select: { firstName: true, lastName: true, avatar: true }, + }, + agentProfile: { + select: { firstName: true, lastName: true, avatar: true }, + }, + }, + }, + }, + }); + + if (!chat) { + throw new NotFoundException('Support chat not found'); + } + + if (!isAdmin && userId && chat.userId !== userId) { + throw new ForbiddenException('You do not have access to this chat'); + } + + return chat; + } + + /** + * Get messages for a support chat with pagination + */ + async getMessages(chatId: string, page = 1, limit = 50) { + const skip = (page - 1) * limit; + + const [messages, total] = await Promise.all([ + this.prisma.supportMessage.findMany({ + where: { chatId }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + this.prisma.supportMessage.count({ where: { chatId } }), + ]); + + return { + messages: messages.reverse(), + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + }, + }; + } + + /** + * Send a message in a support chat + */ + async sendMessage(chatId: string, senderId: string, senderRole: string, content: string) { + const chat = await this.prisma.supportChat.findUnique({ + where: { id: chatId }, + }); + + if (!chat) { + throw new NotFoundException('Support chat not found'); + } + + if (chat.status === SupportChatStatus.CLOSED) { + throw new ForbiddenException('This support chat is closed'); + } + + const message = await this.prisma.supportMessage.create({ + data: { + chatId, + senderId, + senderRole, + content, + }, + }); + + // Update chat metadata + const truncatedContent = + content.length > 255 ? content.substring(0, 252) + '...' : content; + + const isUser = senderRole === 'USER' || senderRole === 'AGENT'; + await this.prisma.supportChat.update({ + where: { id: chatId }, + data: { + lastMessageAt: new Date(), + lastMessageText: truncatedContent, + ...(isUser + ? { adminUnreadCount: { increment: 1 } } + : { userUnreadCount: { increment: 1 } }), + }, + }); + + return message; + } + + /** + * Mark messages as read for a role + */ + async markAsRead(chatId: string, role: string) { + const isUser = role === 'USER' || role === 'AGENT'; + await this.prisma.supportChat.update({ + where: { id: chatId }, + data: isUser ? { userUnreadCount: 0 } : { adminUnreadCount: 0 }, + }); + return { success: true }; + } + + /** + * Admin: get all support chats + */ + async getAllChats(status?: SupportChatStatus) { + const where = status ? { status } : {}; + + return this.prisma.supportChat.findMany({ + where, + include: { + user: { + select: { + id: true, + email: true, + role: true, + userProfile: { + select: { firstName: true, lastName: true, avatar: true }, + }, + agentProfile: { + select: { firstName: true, lastName: true, avatar: true }, + }, + }, + }, + }, + orderBy: { lastMessageAt: 'desc' }, + }); + } + + /** + * Admin: close a support chat + */ + async closeChat(chatId: string) { + const chat = await this.prisma.supportChat.findUnique({ + where: { id: chatId }, + }); + + if (!chat) { + throw new NotFoundException('Support chat not found'); + } + + return this.prisma.supportChat.update({ + where: { id: chatId }, + data: { status: SupportChatStatus.CLOSED }, + }); + } +}