forked from Chatbot/v3-api
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import { HttpException, HttpStatus, Injectable, Optional, Inject } from '@nestjs/common';
|
|
import { InjectModel } from '@nestjs/mongoose';
|
|
import { FilterQuery, Model, Types } from 'mongoose';
|
|
import { BaseResponseDTO } from 'src/common/dto/base-response.dto';
|
|
import { Role } from 'src/common/types/role.type';
|
|
import { AdminDocument, AdminModel } from 'src/database/model/admin.model';
|
|
import { SessionModel } from 'src/database/model/sessions.model';
|
|
import { UserModel } from 'src/database/model/user.model';
|
|
import { ReassignedLogsModel } from 'src/database/model/reassigned-logs.model';
|
|
import { ChatService } from 'src/socket/support-management/chat.service';
|
|
import { RedisService } from 'src/common/helpers/redis.service';
|
|
import { CreateAdminDto } from './dto/create-admin.dto';
|
|
import { EditProfileDto } from './dto/edit-profile.dto';
|
|
import { createExpertDto } from './dto/create-new-expert.dto';
|
|
import * as crypto from 'node:crypto';
|
|
|
|
@Injectable()
|
|
export class AdminService {
|
|
constructor(
|
|
@InjectModel(AdminModel.name)
|
|
private readonly adminModel: Model<AdminModel>,
|
|
@InjectModel(SessionModel.name)
|
|
private readonly sessionModel: Model<SessionModel>,
|
|
@InjectModel(UserModel.name)
|
|
private readonly userModel: Model<UserModel>,
|
|
@InjectModel(ReassignedLogsModel.name)
|
|
private readonly reassignedLogsModel: Model<ReassignedLogsModel>,
|
|
@Optional() @Inject(ChatService)
|
|
private readonly chatService: ChatService | null,
|
|
private readonly redisService: RedisService,
|
|
) {}
|
|
|
|
async create(createAdminDto: CreateAdminDto, role: Role.Admin | Role.Expert | Role.Supervisor) {
|
|
return await this.adminModel.create({
|
|
...createAdminDto,
|
|
username: createAdminDto.email,
|
|
role: role,
|
|
});
|
|
}
|
|
|
|
async findOneAdmin(query: FilterQuery<AdminModel>): Promise<AdminDocument> {
|
|
return await this.adminModel.findOne(query);
|
|
}
|
|
|
|
async getProfile(adminIdentity): Promise<any> {
|
|
try {
|
|
const admin = await this.adminModel
|
|
.findOne(
|
|
{
|
|
_id: new Types.ObjectId(adminIdentity.userData._id),
|
|
isActive: true,
|
|
},
|
|
{ password: 0, resetToken: 0 },
|
|
)
|
|
.populate('avatar');
|
|
|
|
if (!admin) throw new HttpException('not_found', HttpStatus.NOT_FOUND);
|
|
|
|
// Use expert's email to find sessions (expert field stores email)
|
|
const expertEmail = admin.email;
|
|
|
|
// Calculate total answered: sessions where expert actually connected (connectedToExpert === true)
|
|
// and the expert field matches this expert's email
|
|
const answeredSessions = await this.sessionModel.find({
|
|
expert: expertEmail,
|
|
connectedToExpert: true,
|
|
onlineStartDate: { $exists: true },
|
|
});
|
|
|
|
// Calculate activity time (sum of all answered session durations in seconds)
|
|
const activityTime = Math.round(
|
|
answeredSessions.reduce((total, session) => {
|
|
if (session.onlineStartDate && session.onlineEndDate) {
|
|
const duration =
|
|
(session.onlineEndDate.getTime() -
|
|
session.onlineStartDate.getTime()) /
|
|
1000;
|
|
// Only add positive durations to avoid negative activity time
|
|
return total + (duration > 0 ? duration : 0);
|
|
}
|
|
return total;
|
|
}, 0),
|
|
);
|
|
|
|
// Calculate total answered (sessions where expert connected)
|
|
const totalAnswered = answeredSessions.length;
|
|
|
|
// Calculate total not answered: count from reassigned_logs where this expert missed the chat
|
|
// (reassigned from them, meaning they were online but didn't answer)
|
|
const totalNotAnswered = await this.reassignedLogsModel.countDocuments({
|
|
from: expertEmail,
|
|
});
|
|
|
|
// Calculate total sessions (answered + not answered)
|
|
const totalSessions = totalAnswered + totalNotAnswered;
|
|
|
|
// Calculate total rate (average of all non-null rates from answered sessions)
|
|
const rates = answeredSessions
|
|
.filter(
|
|
(session) =>
|
|
session.expertRate !== null && session.expertRate !== undefined,
|
|
)
|
|
.map((session) => session.expertRate);
|
|
const totalRate =
|
|
rates.length > 0
|
|
? parseFloat((rates.reduce((sum, rate) => sum + rate, 0) / rates.length).toFixed(2))
|
|
: 0;
|
|
|
|
return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', {
|
|
_id: admin._id,
|
|
name: admin.name,
|
|
family: admin.family,
|
|
mobile: admin.mobile,
|
|
email: admin.email,
|
|
username: admin.username,
|
|
role: admin.role,
|
|
isActive: admin.isActive,
|
|
avatar: admin.avatar
|
|
? `https://${process.env.BASEURL}/${(admin.avatar as any).filePath}`
|
|
: null,
|
|
birthDate: admin.birthDate,
|
|
reports: {
|
|
activityTime,
|
|
totalSessions,
|
|
totalAnswered,
|
|
totalNotAnswered,
|
|
totalRate,
|
|
currentNotActive: 0, // Will be implemented later
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.log(err);
|
|
throw new HttpException(err.message, HttpStatus.BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
async createNewExpert(body:createExpertDto,adminIdentity:any){
|
|
try {
|
|
const existingExpert = await this.adminModel.findOne<AdminModel | null>({ // Explicitly type existingExpert as AdminModel | null
|
|
$or: [{ email: body.email }, { mobile: body.mobile }],
|
|
});
|
|
|
|
if (existingExpert) {
|
|
throw new HttpException('Expert with this email or mobile already exists', HttpStatus.BAD_REQUEST);
|
|
}
|
|
const hashedPassword = crypto.createHash('sha256').update(body.password).digest('hex');
|
|
|
|
const newExpert = await this.adminModel.create({
|
|
email: body.email,
|
|
password: hashedPassword,
|
|
mobile: body.mobile,
|
|
name: body.name,
|
|
family: body.family,
|
|
role: 'expert',
|
|
username: body.email, // Set username to email
|
|
});
|
|
|
|
// Exclude password from the returned object
|
|
const { password, ...expertWithoutPassword } = newExpert.toObject();
|
|
|
|
return new BaseResponseDTO(HttpStatus.CREATED, 'Expert created successfully', expertWithoutPassword);
|
|
} catch (err) {
|
|
console.log(err);
|
|
throw new HttpException(err.message, HttpStatus.BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
async modifyProfile(body: EditProfileDto, adminIdentity: any) {
|
|
const admin = await this.adminModel.findOne({
|
|
_id: new Types.ObjectId(adminIdentity.userData._id),
|
|
isActive: true,
|
|
});
|
|
|
|
if (!admin) {
|
|
throw new HttpException('not_found', HttpStatus.NOT_FOUND);
|
|
}
|
|
|
|
const updatePayload: any = { ...body };
|
|
|
|
const updatedUser = await this.adminModel
|
|
.findByIdAndUpdate(adminIdentity.userData._id, updatePayload, {
|
|
new: true,
|
|
projection: {
|
|
_id: 1,
|
|
role: 1,
|
|
mobile: 1,
|
|
email: 1,
|
|
username: 1,
|
|
name: 1,
|
|
family: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
birthDate: 1,
|
|
birthday: 1,
|
|
avatar: 1,
|
|
},
|
|
})
|
|
.populate('avatar');
|
|
|
|
return updatedUser;
|
|
}
|
|
|
|
async toggleExpertActivation(expertId: string): Promise<BaseResponseDTO> {
|
|
try {
|
|
const expert = await this.adminModel.findOne({
|
|
_id: new Types.ObjectId(expertId),
|
|
role: Role.Expert,
|
|
});
|
|
|
|
if (!expert) {
|
|
throw new HttpException('Expert not found', HttpStatus.NOT_FOUND);
|
|
}
|
|
|
|
expert.isActive = !expert.isActive;
|
|
await expert.save();
|
|
|
|
const message = expert.isActive ? 'Expert activated successfully' : 'Expert deactivated successfully';
|
|
const { password, ...expertWithoutPassword } = expert.toObject();
|
|
return new BaseResponseDTO(HttpStatus.OK, message, expertWithoutPassword);
|
|
} catch (err) {
|
|
console.log(err);
|
|
throw new HttpException(err.message, HttpStatus.BAD_REQUEST);
|
|
}
|
|
}
|
|
|
|
async getDashboard(): Promise<BaseResponseDTO> {
|
|
try {
|
|
const redisClient = this.redisService.getClient();
|
|
const ONLINE_EXPERTS_KEY = 'onlineExperts';
|
|
const ROOMS_HASH_KEY = 'rooms:active';
|
|
const WAITING_QUEUE_KEY = 'waitingQueue';
|
|
const WAITING_USERS_KEY = 'waiting_users';
|
|
|
|
// Get all experts from Redis onlineExperts hash
|
|
let allExpertsFromRedis: any[] = [];
|
|
try {
|
|
const onlineExpertsEntries = await redisClient.hgetall(ONLINE_EXPERTS_KEY);
|
|
allExpertsFromRedis = Object.values(onlineExpertsEntries)
|
|
.map((v) => {
|
|
try {
|
|
return JSON.parse(v as string);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((expert: any) => expert);
|
|
} catch (err) {
|
|
console.error('Error getting experts from Redis:', err);
|
|
}
|
|
|
|
// 1. تعداد کارشناس در حال مکالمه - Number of experts currently in conversation
|
|
// Experts who have activeSessions > 0
|
|
const expertsInConversation = allExpertsFromRedis.filter(
|
|
(expert: any) => expert.activeSessions > 0
|
|
);
|
|
const expertsInConversationCount = expertsInConversation.length;
|
|
|
|
// 2. تعداد کارشناس آنلاین - Number of online experts
|
|
// Experts who are marked as online OR have active sessions (if in conversation, they should be online)
|
|
const onlineExpertIds = new Set<string>();
|
|
allExpertsFromRedis.forEach((expert: any) => {
|
|
if (expert.isOnline === true || expert.activeSessions > 0) {
|
|
onlineExpertIds.add(expert.expertId);
|
|
}
|
|
});
|
|
const onlineExpertsCount = onlineExpertIds.size;
|
|
|
|
// 3. تعداد کاربر در حال مکاتبه با کارشناس - Number of users currently chatting with expert
|
|
// Each room in rooms:active represents one active chat session
|
|
// Since each user can only have one active expert session, counting active rooms = counting unique users
|
|
let usersWithExpert = 0;
|
|
try {
|
|
if (this.chatService) {
|
|
const activeRooms = await this.chatService.getActiveRooms();
|
|
usersWithExpert = activeRooms ? activeRooms.length : 0;
|
|
} else {
|
|
// Fallback: get directly from Redis if chatService is not available
|
|
const roomsEntries = await redisClient.hgetall(ROOMS_HASH_KEY);
|
|
const activeRooms = Object.values(roomsEntries)
|
|
.map((v) => {
|
|
try {
|
|
return JSON.parse(v as string);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((room: any) => room && room.isActive);
|
|
|
|
usersWithExpert = activeRooms.length;
|
|
}
|
|
} catch (err) {
|
|
console.error('Error getting users with expert from Redis:', err);
|
|
}
|
|
|
|
// 4. تعداد کاربر در انتظار ارتباط با کارشناس - Number of users waiting to connect with expert
|
|
// Get from Redis waitingQueue list and waiting_users hash
|
|
let waitingUsersCount = 0;
|
|
try {
|
|
const waitingQueueLength = await redisClient.llen(WAITING_QUEUE_KEY);
|
|
const waitingUsersKeys = await redisClient.hkeys(WAITING_USERS_KEY);
|
|
waitingUsersCount = waitingQueueLength + waitingUsersKeys.length;
|
|
} catch (err) {
|
|
console.error('Error getting waiting users from Redis:', err);
|
|
}
|
|
|
|
// 5. تعداد کاربر چت بات - Number of chatbot users (total unique users with sessions)
|
|
const totalChatbotUsers = await this.sessionModel.distinct('userId').then(ids => ids.length);
|
|
|
|
// 6. تعداد کاربر بیمه گذار چت بات - Number of insurance holder chatbot users
|
|
// Users with nationalCode who have sessions
|
|
const usersWithSessions = await this.sessionModel.distinct('userId');
|
|
const insuranceHolderUsers = await this.userModel.countDocuments({
|
|
_id: { $in: usersWithSessions },
|
|
nationalCode: { $exists: true, $nin: [null, ''] },
|
|
});
|
|
|
|
return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', {
|
|
expertsInConversation: expertsInConversationCount,
|
|
onlineExperts: onlineExpertsCount,
|
|
usersWithExpert: usersWithExpert,
|
|
waitingUsers: waitingUsersCount,
|
|
totalChatbotUsers: totalChatbotUsers,
|
|
insuranceHolderUsers: insuranceHolderUsers,
|
|
});
|
|
} catch (err) {
|
|
console.error('Error in getDashboard:', err);
|
|
throw new HttpException(
|
|
err.message || 'Failed to fetch dashboard data',
|
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
|
);
|
|
}
|
|
}
|
|
}
|