RocketChat/Rocket.Chat

View on GitHub
apps/meteor/app/livechat/server/lib/QueueManager.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { Apps, AppEvents } from '@rocket.chat/apps';
import { Omnichannel } from '@rocket.chat/core-services';
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import {
    LivechatInquiryStatus,
    type ILivechatInquiryRecord,
    type ILivechatVisitor,
    type IOmnichannelRoom,
    type SelectedAgent,
    type OmnichannelSourceType,
} from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { LivechatDepartment, LivechatDepartmentAgents, LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { dispatchInquiryPosition } from '../../../../ee/app/livechat-enterprise/server/lib/Helper';
import { callbacks } from '../../../../lib/callbacks';
import { sendNotification } from '../../../lib/server';
import {
    notifyOnLivechatInquiryChangedById,
    notifyOnLivechatInquiryChanged,
    notifyOnSettingChanged,
} from '../../../lib/server/lib/notifyListener';
import { settings } from '../../../settings/server';
import { i18n } from '../../../utils/lib/i18n';
import { createLivechatRoom, createLivechatInquiry, allowAgentSkipQueue } from './Helper';
import { Livechat } from './LivechatTyped';
import { RoutingManager } from './RoutingManager';
import { getInquirySortMechanismSetting } from './settings';

const logger = new Logger('QueueManager');

export const saveQueueInquiry = async (inquiry: ILivechatInquiryRecord) => {
    const queuedInquiry = await LivechatInquiry.queueInquiry(inquiry._id);
    if (!queuedInquiry) {
        return;
    }

    await callbacks.run('livechat.afterInquiryQueued', queuedInquiry);

    void notifyOnLivechatInquiryChanged(queuedInquiry, 'updated', {
        status: LivechatInquiryStatus.QUEUED,
        queuedAt: new Date(),
        takenAt: undefined,
    });
};

/**
 *  @deprecated
 */
export const queueInquiry = async (inquiry: ILivechatInquiryRecord, defaultAgent?: SelectedAgent) => {
    const room = await LivechatRooms.findOneById(inquiry.rid, { projection: { v: 1 } });

    if (!room) {
        await saveQueueInquiry(inquiry);
        return;
    }

    return QueueManager.requeueInquiry(inquiry, room, defaultAgent);
};

const getDepartment = async (department: string): Promise<string | undefined> => {
    if (!department) {
        return;
    }

    if (await LivechatDepartmentAgents.checkOnlineForDepartment(department)) {
        return department;
    }

    const departmentDocument = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'fallbackForwardDepartment'>>(
        department,
        {
            projection: { fallbackForwardDepartment: 1 },
        },
    );

    if (departmentDocument?.fallbackForwardDepartment) {
        return getDepartment(departmentDocument.fallbackForwardDepartment);
    }
};

export class QueueManager {
    static async requeueInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, defaultAgent?: SelectedAgent) {
        if (!(await Omnichannel.isWithinMACLimit(room))) {
            logger.error({ msg: 'MAC limit reached, not routing inquiry', inquiry });
            // We'll queue these inquiries so when new license is applied, they just start rolling again
            // Minimizing disruption
            await saveQueueInquiry(inquiry);
            return;
        }

        const inquiryAgent = await RoutingManager.delegateAgent(defaultAgent, inquiry);
        logger.debug(`Delegating inquiry with id ${inquiry._id} to agent ${defaultAgent?.username}`);
        await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent);
        const dbInquiry = await LivechatInquiry.findOneById(inquiry._id);

        if (!dbInquiry) {
            throw new Error('inquiry-not-found');
        }

        if (dbInquiry.status === 'ready') {
            logger.debug(`Inquiry with id ${inquiry._id} is ready. Delegating to agent ${inquiryAgent?.username}`);
            return RoutingManager.delegateInquiry(dbInquiry, inquiryAgent, undefined, room);
        }
    }

    private static fnQueueInquiryStatus: (typeof QueueManager)['getInquiryStatus'] | undefined;

    public static patchInquiryStatus(fn: (typeof QueueManager)['getInquiryStatus']) {
        this.fnQueueInquiryStatus = fn;
    }

    static async getInquiryStatus({ room, agent }: { room: IOmnichannelRoom; agent?: SelectedAgent }): Promise<LivechatInquiryStatus> {
        if (this.fnQueueInquiryStatus) {
            return this.fnQueueInquiryStatus({ room, agent });
        }

        if (!(await Omnichannel.isWithinMACLimit(room))) {
            return LivechatInquiryStatus.QUEUED;
        }

        if (RoutingManager.getConfig()?.autoAssignAgent) {
            return LivechatInquiryStatus.READY;
        }

        if (!agent || !(await allowAgentSkipQueue(agent))) {
            return LivechatInquiryStatus.QUEUED;
        }

        return LivechatInquiryStatus.READY;
    }

    static async queueInquiry(inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, defaultAgent?: SelectedAgent | null) {
        if (inquiry.status === 'ready') {
            return RoutingManager.delegateInquiry(inquiry, defaultAgent, undefined, room);
        }

        await callbacks.run('livechat.afterInquiryQueued', inquiry);

        void callbacks.run('livechat.chatQueued', room);

        await this.dispatchInquiryQueued(inquiry, room, defaultAgent);
    }

    static async requestRoom<
        E extends Record<string, unknown> & {
            sla?: string;
            customFields?: Record<string, unknown>;
            source?: OmnichannelSourceType;
        },
    >({
        guest,
        rid = Random.id(),
        message,
        roomInfo,
        agent,
        extraData: { customFields, ...extraData } = {} as E,
    }: {
        guest: ILivechatVisitor;
        rid?: string;
        message?: string;
        roomInfo: {
            source?: IOmnichannelRoom['source'];
            [key: string]: unknown;
        };
        agent?: SelectedAgent;
        extraData?: E;
    }) {
        logger.debug(`Requesting a room for guest ${guest._id}`);
        check(
            guest,
            Match.ObjectIncluding({
                _id: String,
                username: String,
                status: Match.Maybe(String),
                department: Match.Maybe(String),
                name: Match.Maybe(String),
                activity: Match.Maybe([String]),
            }),
        );

        const defaultAgent =
            (await callbacks.run('livechat.beforeDelegateAgent', agent, {
                department: guest.department,
            })) || undefined;

        const department = guest.department && (await getDepartment(guest.department));

        /**
         * we have 4 cases here
         * 1. agent and no department
         * 2. no agent and no department
         * 3. no agent and department
         * 4. agent and department informed
         *
         * in case 1, we check if the agent is online
         * in case 2, we check if there is at least one online agent in the whole service
         * in case 3, we check if there is at least one online agent in the department
         *
         * the case 4 is weird, but we are not throwing an error, just because the application works in some mysterious way
         * we don't have explicitly defined what to do in this case so we just kept the old behavior
         * it seems that agent has priority over department
         * but some cases department is handled before agent
         *
         */

        if (!settings.get('Livechat_accept_chats_with_no_agents')) {
            if (agent && !defaultAgent) {
                throw new Meteor.Error('no-agent-online', 'Sorry, no online agents');
            }

            if (!defaultAgent && guest.department && !department) {
                throw new Meteor.Error('no-agent-online', 'Sorry, no online agents');
            }

            if (!agent && !guest.department && !(await Livechat.checkOnlineAgents())) {
                throw new Meteor.Error('no-agent-online', 'Sorry, no online agents');
            }
        }

        const name = (roomInfo?.fname as string) || guest.name || guest.username;

        const room = await createLivechatRoom(rid, name, { ...guest, ...(department && { department }) }, roomInfo, {
            ...extraData,
            ...(Boolean(customFields) && { customFields }),
        });

        if (!room) {
            logger.error(`Room for visitor ${guest._id} not found`);
            throw new Error('room-not-found');
        }
        logger.debug(`Room for visitor ${guest._id} created with id ${room._id}`);

        const inquiry = await createLivechatInquiry({
            rid,
            name,
            initialStatus: await this.getInquiryStatus({ room, agent: defaultAgent }),
            guest,
            message,
            extraData: { ...extraData, source: roomInfo.source },
        });

        if (!inquiry) {
            logger.error(`Inquiry for visitor ${guest._id} not found`);
            throw new Error('inquiry-not-found');
        }

        void Apps.self?.triggerEvent(AppEvents.IPostLivechatRoomStarted, room);

        const livechatSetting = await LivechatRooms.updateRoomCount();
        if (livechatSetting) {
            void notifyOnSettingChanged(livechatSetting);
        }

        const newRoom = (await this.queueInquiry(inquiry, room, defaultAgent)) ?? (await LivechatRooms.findOneById(rid));
        if (!newRoom) {
            logger.error(`Room with id ${rid} not found`);
            throw new Error('room-not-found');
        }

        if (!newRoom.servedBy && settings.get('Omnichannel_calculate_dispatch_service_queue_statistics')) {
            const [inq] = await LivechatInquiry.getCurrentSortedQueueAsync({
                inquiryId: inquiry._id,
                department,
                queueSortBy: getInquirySortMechanismSetting(),
            });

            if (inq) {
                void dispatchInquiryPosition(inq);
            }
        }

        return newRoom;
    }

    static async unarchiveRoom(archivedRoom: IOmnichannelRoom) {
        if (!archivedRoom) {
            throw new Error('no-room-to-unarchive');
        }

        const { _id: rid, open, closedAt, fname: name, servedBy, v, departmentId: department, lastMessage: message, source } = archivedRoom;

        if (!rid || !closedAt || !!open) {
            return archivedRoom;
        }

        logger.debug(`Attempting to unarchive room with id ${rid}`);

        const oldInquiry = await LivechatInquiry.findOneByRoomId<Pick<ILivechatInquiryRecord, '_id'>>(rid, { projection: { _id: 1 } });
        if (oldInquiry) {
            logger.debug(`Removing old inquiry (${oldInquiry._id}) for room ${rid}`);
            await LivechatInquiry.removeByRoomId(rid);
            void notifyOnLivechatInquiryChangedById(oldInquiry._id, 'removed');
        }

        const guest = {
            ...v,
            ...(department && { department }),
        };

        let defaultAgent: SelectedAgent | undefined;
        if (servedBy?.username && (await Users.findOneOnlineAgentByUserList(servedBy.username))) {
            defaultAgent = { agentId: servedBy._id, username: servedBy.username };
        }

        await LivechatRooms.unarchiveOneById(rid);
        const room = await LivechatRooms.findOneById(rid);
        if (!room) {
            throw new Error('room-not-found');
        }
        const inquiry = await createLivechatInquiry({
            rid,
            name,
            guest,
            message: message?.msg,
            extraData: { source },
        });
        if (!inquiry) {
            throw new Error('inquiry-not-found');
        }

        await this.requeueInquiry(inquiry, room, defaultAgent);
        logger.debug(`Inquiry ${inquiry._id} queued`);

        return room;
    }

    private static dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, room: IOmnichannelRoom, agent?: SelectedAgent | null) => {
        logger.debug(`Notifying agents of new inquiry ${inquiry._id} queued`);

        const { department, rid, v } = inquiry;
        // Alert only the online agents of the queued request
        const onlineAgents = await Livechat.getOnlineAgents(department, agent);

        if (!onlineAgents) {
            logger.debug('Cannot notify agents of queued inquiry. No online agents found');
            return;
        }

        logger.debug(`Notifying ${await onlineAgents.count()} agents of new inquiry`);
        const notificationUserName = v && (v.name || v.username);

        for await (const agent of onlineAgents) {
            const { _id, active, emails, language, status, statusConnection, username } = agent;
            await sendNotification({
                // fake a subscription in order to make use of the function defined above
                subscription: {
                    rid,
                    u: {
                        _id,
                    },
                    receiver: [
                        {
                            active,
                            emails,
                            language,
                            status,
                            statusConnection,
                            username,
                        },
                    ],
                    name: '',
                },
                sender: v,
                hasMentionToAll: true, // consider all agents to be in the room
                hasReplyToThread: false,
                disableAllMessageNotifications: false,
                hasMentionToHere: false,
                message: { _id: '', u: v, msg: '' },
                // we should use server's language for this type of messages instead of user's
                notificationMessage: i18n.t('User_started_a_new_conversation', { username: notificationUserName }, language),
                room: { ...room, name: i18n.t('New_chat_in_queue', {}, language) },
                mentionIds: [],
            });
        }
    };
}