import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { FilterQuery, Model, Types, UpdateQuery } from 'mongoose'; import { BaseResponseDTO } from 'src/common/dto/base-response.dto'; import { TimeHelper } from 'src/common/tools/time-helper'; import { Sender } from 'src/common/types/sender.type'; import { ReactEnum } from 'src/common/types/react.type'; import { AdminModel } from 'src/database/model/admin.model'; import { DictionariesModel } from 'src/database/model/dictionaries.model'; import { SessionModel } from 'src/database/model/sessions.model'; import { UserModel } from 'src/database/model/user.model'; import { ReactsModel } from 'src/database/model/botReact.model'; import { AiServiceService } from '../../ai-service/ai-service.service'; import { PoliciesService } from 'src/policies/policies.service'; import { UserAskQuestion, UserRateTheExpert, UserReactToMessage, } from './dto/user.dto'; @Injectable() export class UserService { constructor( @InjectModel(UserModel.name) private readonly user: Model, @InjectModel(SessionModel.name) private readonly session: Model, @InjectModel(AdminModel.name) private readonly admin: Model, @InjectModel(DictionariesModel.name) private readonly dictionaries: Model, @InjectModel(ReactsModel.name) private readonly reacts: Model, private readonly aiService: AiServiceService, private readonly policiesService: PoliciesService, ) {} private normalizeReactValue(value: string | ReactEnum): ReactEnum { if (value === ReactEnum.like || value === ReactEnum.dislike || value === ReactEnum.nothing) { return value as ReactEnum; } if (typeof value === 'string') { const v = value.toLowerCase(); if (v === 'like') return ReactEnum.like; if (v === 'dislike') return ReactEnum.dislike; if (v === 'nothing') return ReactEnum.nothing; } return ReactEnum.nothing; } async getMyPolicies(user) { try { const userId = typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; const data = await this.policiesService.getUserPoliciesWithInstallments( userId, ); return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', data); } catch (err) { console.log(err); throw new BaseResponseDTO( HttpStatus.INTERNAL_SERVER_ERROR, 'Failed to fetch policies', null, ); } } async getProfile(user) { try { const userId = typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; const userDoc = await this.user.findOne({ _id: userId }); if (!userDoc) throw new HttpException('user_not_found', HttpStatus.NOT_FOUND); return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { _id: userDoc._id, name: userDoc.name || null, family: userDoc.family || null, birthDate: userDoc.birthDate || null, nationalCode: userDoc.nationalCode || null, mobile: userDoc.mobile || null, email: userDoc.email || null, address: userDoc.address || null, username: userDoc.username || null, }); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } async getUserLastQuestionsHistory(user) { try { const now = Date.now() / 1000; const userId = typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; const sessions = await this.session .find({ userId: userId }) .select('chatTitle _id createdAt createdISO') .lean() .exec(); // Calculate date boundaries const nowMs = Date.now(); const nowDate = new Date(nowMs); // 7 days ago const sevenDaysAgo = nowMs - (7 * 24 * 60 * 60 * 1000); // 6 months ago (using actual month calculation) const sixMonthsAgoDate = new Date(nowDate); sixMonthsAgoDate.setMonth(sixMonthsAgoDate.getMonth() - 6); const sixMonthsAgo = sixMonthsAgoDate.getTime(); // 12 months ago (using actual month calculation) const twelveMonthsAgoDate = new Date(nowDate); twelveMonthsAgoDate.setMonth(twelveMonthsAgoDate.getMonth() - 12); const twelveMonthsAgo = twelveMonthsAgoDate.getTime(); // Initialize categorized arrays const recent: any[] = []; // Last 7 days const lastSixMonths: any[] = []; // Last 6 months (excluding last 7 days) const previousSixMonths: any[] = []; // 6-12 months ago if (sessions && sessions.length > 0) { for (let session of sessions) { const sessionData = { title: session["chatTitle"], sessionId: session["_id"], time: session["createdAt"][0], date: session["createdAt"][1], }; // Use createdISO for accurate date comparison const sessionDate = session["createdISO"] ? new Date(session["createdISO"]).getTime() : null; if (sessionDate) { if (sessionDate >= sevenDaysAgo) { // Last 7 days (recent) recent.push(sessionData); } else if (sessionDate >= sixMonthsAgo) { // Last 6 months (excluding last 7 days) lastSixMonths.push(sessionData); } else if (sessionDate >= twelveMonthsAgo) { // Previous 6 months (6-12 months ago) previousSixMonths.push(sessionData); } // Sessions older than 12 months are not included } else { // Fallback: if createdISO is missing, include in recent (shouldn't happen normally) recent.push(sessionData); } } } return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { recent, // Last 7 days lastSixMonths, // Last 6 months (excluding last 7 days) previousSixMonths, // 6-12 months ago now: TimeHelper.unix2PersianTimeAndDate(now), }); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } async userSessionHistory(user, sessionId) { try { console.log('userSessionHistory called with sessionId:', sessionId); if (!sessionId || !Types.ObjectId.isValid(sessionId)) { console.error('Invalid sessionId provided to userSessionHistory:', sessionId); throw new HttpException('invalid_session_id', HttpStatus.BAD_REQUEST); } const newSession = await this.session .findOne({ _id: new Types.ObjectId(sessionId), // userId: user._id, }) .exec(); if (!newSession) { throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); } return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { sessionId: newSession._id, chatTitle: newSession.chatTitle, connectedToExpert: newSession.connectedToExpert, onlineChatClosed: newSession.onlineChatClosed, chatClosed: newSession.chatClosed, count: `${newSession.messages.filter((message) => message.sender === Sender.Bot).length}/20`, time: newSession.createdAt[0], date: newSession.createdAt[1], messages: newSession.messages, }); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } async rateTheExpert(body: UserRateTheExpert, user) { try { const now = Date.now() / 1000; const userId = typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; // Fetch the session from the `sessions` collection const sessionIdToFind = new Types.ObjectId(body.sessionId); const newSession = await this.session .findOne({ _id: sessionIdToFind, userId: userId, // Ensure the session belongs to the user }) .exec(); if (!newSession) { throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); } if (!newSession.connectedToExpert) { throw new HttpException('expert_not_connected', HttpStatus.BAD_REQUEST); } // Check if the expert field is empty or null if (!newSession.expert || newSession.expert.trim() === '') { throw new HttpException('expert_not_assigned', HttpStatus.BAD_REQUEST); } // Update the expert rate in the `sessions` collection await this.session.updateOne( { _id: sessionIdToFind }, { $set: { expertRate: body.rate, }, }, ); // Update the admin's rate in the `admin` collection await this.admin.updateOne( { username: newSession.expert }, { $push: { rate: { sessionId: body.sessionId, rate: body.rate, createdAt: TimeHelper.unix2PersianTimeAndDate(now), createdAtDate: Date.now(), }, }, }, ); // Return success response return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', null); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } async getUsersFaq(user) { try { const dictionaries = await this.dictionaries.find({ isActive: true, aiCollectionName: { $ne: 'agents' } }); // Combine all questions from all dictionaries into a single array const allQuestions = dictionaries.flatMap( (dictionary) => dictionary.questions, ); // Filter out questions where deleted is true const filteredQuestions = allQuestions.filter( (question) => question.deleted !== true, ); // Extract only the `question` field from each question object const questionsOnly = filteredQuestions.map( (question) => question.question, ); // Function to shuffle an array using Fisher-Yates algorithm const shuffleArray = (array) => { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; }; // Shuffle the questions and pick the first 3 const shuffledQuestions = shuffleArray(questionsOnly); const randomQuestions = shuffledQuestions.slice(0, 3); return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', randomQuestions); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } async react(body: UserReactToMessage, userData) { try { const { sessionId, messageId } = body; const normalizedReact = this.normalizeReactValue((body as any).react); const sessionObjectId = new Types.ObjectId(sessionId); const userObjectId = new Types.ObjectId(userData._id); const storedMessageId = this.toStoredMessageId(messageId); // Update the message's react field in the session // Use updateOne first to ensure the update happens, then fetch the result const updateResult = await this.session.updateOne( { _id: sessionObjectId, 'messages.messageId': storedMessageId, }, { $set: { 'messages.$.react': normalizedReact, }, }, ); if (updateResult.matchedCount === 0) { throw new HttpException('message_not_found', HttpStatus.NOT_FOUND); } // Fetch the updated session to get the reacted message const result = await this.session.findById(sessionObjectId); if (!result) { throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); } const reactedMessage = result.messages.find( (message) => String(message.messageId) === String(messageId), ); // Verify the update actually happened if (reactedMessage && reactedMessage.react !== normalizedReact) { console.warn(`[react] Warning: React update may have failed. Expected: ${normalizedReact}, Got: ${reactedMessage.react}`); } if (reactedMessage) { const botMessageIndex = result.messages.findIndex( (message) => String(message.messageId) === String(messageId), ); let questionText = null; if (botMessageIndex > 0) { for (let i = botMessageIndex - 1; i >= 0; i--) { if (result.messages[i].sender === Sender.User) { questionText = result.messages[i].text; break; } } } // Update or create the react entry in the reacts collection // This ensures we only have one react per user per message (the latest one) await this.reacts.findOneAndUpdate( { sessionId: sessionObjectId, messageId: storedMessageId, userId: userObjectId, }, { $set: { question: questionText, answer: reactedMessage.text, sender: reactedMessage.sender, react: normalizedReact, createdAt: TimeHelper.unix2PersianTimeAndDate(Date.now() / 1000), createdISO: new Date(), }, }, { upsert: true, // Create if doesn't exist, update if it does new: true, }, ); } return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { sessionId: result._id, chatTitle: result.chatTitle, count: `${result.messages.filter((message) => message.sender === Sender.Bot).length}/20`, time: result.createdAt[0], date: result.createdAt[1], messages: result.messages, }); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } private toStoredMessageId(messageId: string): Types.ObjectId | string { if ( Types.ObjectId.isValid(messageId) && String(new Types.ObjectId(messageId)) === String(messageId) ) { return new Types.ObjectId(messageId); } return messageId; } public async insertNewChatNewStructure(sessionId, text, sender, now) { const newMessage = { messageId: new Types.ObjectId(), text: text, sender: sender, react: sender === Sender.User ? 'Nothing' : null, createdAt: TimeHelper.unix2PersianTimeAndDate(now), createdISO: Date.now(), }; await this.session.updateOne( { _id: sessionId }, { $push: { messages: newMessage, }, }, ); } public async editChatMessage( sessionId: string, messageId: string, newMessageText: string, ): Promise { const sessionObjectId = new Types.ObjectId(sessionId); const storedMessageId = this.toStoredMessageId(messageId); const result = await this.session.findOneAndUpdate( { _id: sessionObjectId, 'messages.messageId': storedMessageId, }, { $set: { 'messages.$.text': newMessageText, 'messages.$.edited': true, }, }, { new: true }, ); if (!result) { return null; } // Find the updated message within the session's messages array const updatedMessage = result.messages.find( (msg) => msg.messageId.toString() === messageId, ); return { ...updatedMessage, sender: updatedMessage.sender, // Ensure sender role is explicitly returned }; } async ask(body: UserAskQuestion, user) { try { if (!body.question) { throw new HttpException('question_required', HttpStatus.BAD_REQUEST); } const now = Date.now() / 1000; if (!body.sessionId) { return await this.handleNewSession(body, user, now); } else { return await this.handleExistingSession(body, user, now); } } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } async handleNewSession(body, user, now) { try { console.log('new session'); let aiResponse; let isAiServiceAvailable = true; try { const timeoutMs = parseInt(process.env.AI_SERVICE_TIMEOUT || '65000', 10); // Default 60 seconds const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject( new HttpException('ai_service_timeout', HttpStatus.GATEWAY_TIMEOUT), ); }, timeoutMs); }); const aiRequest = await this.buildAiRequestPayload(body, user, []); aiResponse = await Promise.race([ this.aiService.ask(aiRequest, null), timeoutPromise, ]); if (aiResponse.statusCode == 500) { isAiServiceAvailable = false; } } catch (aiError) { // Catch any AI service errors (timeout, network errors, 500 errors, etc.) console.log('AI service error caught:', aiError); isAiServiceAvailable = false; } let messages; if (!isAiServiceAvailable) { // AI service is unavailable, insert fallback message messages = [ { messageId: new Types.ObjectId(), text: body.question, sender: Sender.User, react: 'Nothing', createdAt: TimeHelper.unix2PersianTimeAndDate(now), createdISO: Date.now(), }, { messageId: new Types.ObjectId(), text: "به دلیل اختلال در سیستم زیر ساخت های کشور سرویس هوش مصنوعی در دسترس نیست. لطفا جهت دریافت راهنمایی به کارشناس متصل شوید. ", sender: Sender.Bot, react: null, createdAt: TimeHelper.unix2PersianTimeAndDate(now), createdISO: Date.now(), }, ]; } else { // AI service responded successfully messages = [ { messageId: new Types.ObjectId(), text: body.question, sender: Sender.User, react: 'Nothing', createdAt: TimeHelper.unix2PersianTimeAndDate(now), createdISO: Date.now(), }, { messageId: new Types.ObjectId(), text: aiResponse, sender: Sender.Bot, react: null, createdAt: TimeHelper.unix2PersianTimeAndDate(now), createdISO: Date.now(), }, ]; } const userId = typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; const newSession = { _id: new Types.ObjectId(), userId: userId, chatTitle: body.question, // TODO: AI wrap-up needed connectedToExpert: false, onlineChatClosed: false, chatClosed: false, expert: '', expertRate: null, createdAt: TimeHelper.unix2PersianTimeAndDate(now), createdISO: Date.now(), messages: messages, }; await this.session.create(newSession); return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { sessionId: newSession._id, count: `1/20`, date: TimeHelper.unix2PersianTimeAndDate(now)[1], time: TimeHelper.unix2PersianTimeAndDate(now)[0], question: body.question, answer: messages[1].text, history: newSession.messages, }); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } async handleExistingSession(body, user, now) { try { const sessionIdToFind = new Types.ObjectId(body.sessionId); const userId = typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; const newSession = await this.session .findOne({ _id: sessionIdToFind, userId: userId, }) .exec(); if (!newSession) { throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); } if (newSession.chatClosed) { throw new HttpException( 'session_limit_reached', HttpStatus.BAD_REQUEST, ); } let aiResponse; let isAiServiceAvailable = true; const fallbackMessage = "به دلیل اختلال در سیستم زیر ساخت های کشور سرویس هوش مصنوعی در دسترس نیست. لطفا جهت دریافت راهنمایی به کارشناس متصل شوید. "; try { // Take last 6 messages (3 complete user-assistant pairs) instead of 7 // to ensure we have complete pairs for chat_history_raw const historyToSend = newSession.messages.slice(-6); // Get the last 6 messages (3 pairs) const transformedData = await this.buildAiRequestPayload( body, user, this.transformChatHistory(historyToSend), ); aiResponse = await this.aiService.ask( transformedData, body.sessionId, ); if (aiResponse.statusCode == 500) { isAiServiceAvailable = false; } } catch (aiError) { // Catch any AI service errors (network errors, 500 errors, etc.) console.log('AI service error caught in existing session:', aiError); isAiServiceAvailable = false; } // Insert user question await this.insertNewChatNewStructure( sessionIdToFind, body.question, Sender.User, now, ); // Insert AI response or fallback message const responseText = isAiServiceAvailable ? aiResponse : fallbackMessage; await this.insertNewChatNewStructure( sessionIdToFind, responseText, Sender.Bot, now, ); const updatedSession = await this.session .findOne({ _id: sessionIdToFind, userId: userId, }) .exec(); const botMessageCount = updatedSession.messages.filter( (message) => message.sender === Sender.Bot, ).length; if (botMessageCount >= 20) { await this.session.updateOne( { _id: sessionIdToFind }, { $set: { chatClosed: true } }, ); } return new BaseResponseDTO(HttpStatus.OK, 'SUCCESS', { sessionId: updatedSession._id, count: `${botMessageCount}/20`, createdAt: TimeHelper.unix2PersianTimeAndDate(now), question: body.question, answer: responseText, history: updatedSession.messages, }); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } transformChatHistory(messages) { const history = []; for (let i = 0; i < messages.length; i += 2) { const userMessage = messages[i]; const botMessage = messages[i + 1]; if (userMessage && botMessage) { history.push({ user: userMessage.text, assistant: botMessage.text, // userCreatedAt: userMessage.createdAt, // botCreatedAt: botMessage.createdAt, }); } } return history; } private async buildAiRequestPayload( body: UserAskQuestion, user: { _id: Types.ObjectId | string }, chatHistory: { user: string; assistant: string }[], ) { const userId = typeof user._id === 'string' ? new Types.ObjectId(user._id) : user._id; const userDoc = await this.user .findById(userId) .select('nationalCode') .lean() .exec(); const { user_insurance_data, user_installments_data } = await this.policiesService.getUserDataForAi( userId, userDoc?.nationalCode, ); return { user_input: body.question, chat_history_raw: chatHistory, user_insurance_data, user_installments_data, }; } async closeChatSession(userId, sessionId) { // ? migrated try { // Close the chat session in the old structure ** old-struc ** await this.user.updateOne( { _id: userId }, { $set: { 'chat.$[chatElement].chatClosed': true, }, }, { arrayFilters: [ { 'chatElement.sessionId': new Types.ObjectId(sessionId) }, ], }, ); // ** old-struc ** // Close the chat session in the new structure ** new-struc ** const sessionIdToFind = new Types.ObjectId(sessionId); await this.session.updateOne( { _id: sessionIdToFind, userId: userId }, { $set: { chatClosed: true } }, ); // Log the new structure data for verification ** new-struc ** await this.session .findOne({ _id: sessionIdToFind, userId: userId, }) .exec(); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } async findOneUser(filter: FilterQuery): Promise { return await this.user.findOne(filter); } async updateOneUser( filter: FilterQuery, update: UpdateQuery, ): Promise { return await this.user.updateOne(filter, update).lean(); } // public async toggleExpertActions( // userId: string, // sessionId: string, // field: string, // ): Promise { // try { // // Validate the field // if (!['connectedToExpert', 'onlineChatClosed'].includes(field)) { // throw new Error('Invalid field specified'); // } // // Toggle the field in the new structure // const sessionIdToFind = new Types.ObjectId(sessionId); // const result = await this.session.updateOne( // { _id: sessionIdToFind, userId: userId }, // { $set: { [field]: true } }, // ); // // Check if the update was successful // if (result.matchedCount === 0) { // throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); // } // // Log the new structure data for verification // const updatedSession = await this.session // .findOne({ // _id: sessionIdToFind, // userId: userId, // }) // .exec(); // } catch (err) { // console.log(err); // throw new BaseResponseDTO(err.status, err.response, null); // } // } public async toggleExpertActions( userId: string, sessionId: string, field: string, expertId?: string, ): Promise { try { console.log('toggleExpertActions called with:', { userId, sessionId, field, expertId }); if (!['connectedToExpert', 'onlineChatClosed'].includes(field)) { throw new Error('Invalid field specified'); } // Additional validation for expertId when field is connectedToExpert if (field === 'connectedToExpert' && !expertId) { throw new Error('expertId is required when field is connectedToExpert'); } const sessionIdToFind = new Types.ObjectId(sessionId); const userID = typeof userId === 'string' ? new Types.ObjectId(userId) : userId; const session = await this.session.findOne({ _id: sessionIdToFind, userId: userID }).exec(); if (!session) { throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); } // New validation logic if (field === 'connectedToExpert' && session.connectedToExpert && session.chatClosed) { console.error('User already connected to expert in a closed session for sessionId:', sessionId); throw new HttpException('you_have_connected_to_expert_once', HttpStatus.BAD_REQUEST); } let updateData: any = { [field]: true }; // If connecting to expert, get expert username and add to update if (field === 'connectedToExpert' && expertId) { console.log('Attempting to find expert with expertId:', expertId); const expert = await this.admin .findOne({ _id: new Types.ObjectId(expertId) }, { username: 1 }) .exec(); if (!expert) { console.error('Expert not found for expertId:', expertId); throw new HttpException('expert_not_found', HttpStatus.NOT_FOUND); } updateData.expert = expert.username; } // Update the session console.log('Updating session with sessionIdToFind:', sessionIdToFind, 'and userID:', userID); const result = await this.session.updateOne( { _id: sessionIdToFind, userId: userID }, { $set: updateData }, ); if (result.matchedCount === 0) { throw new HttpException('session_not_found', HttpStatus.NOT_FOUND); } await this.session .findOne({ _id: sessionIdToFind, userId: userID, }) .exec(); } catch (err) { console.log(err); if (err instanceof HttpException) { throw err; } throw new BaseResponseDTO(err.status, err.response, null); } } }