feat: implement support chat functionality with new module, service, controller, DTOs, and schema updates.

This commit is contained in:
pradeepkumar
2026-03-05 06:37:07 +05:30
parent c1fa1f4aab
commit 399b8aa937
10 changed files with 530 additions and 0 deletions

View File

@@ -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: [

View File

@@ -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
*/

View File

@@ -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],

View File

@@ -0,0 +1 @@
export { SendSupportMessageDto } from './send-support-message.dto';

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
export { SupportChatModule } from './support-chat.module';
export { SupportChatService } from './support-chat.service';
export { SupportChatController } from './support-chat.controller';

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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 },
});
}
}