Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import { AppEvents, Apps } from '@rocket.chat/apps';
|
|
import type { LivechatDepartmentDTO, ILivechatDepartment, ILivechatDepartmentAgents, ILivechatAgent } from '@rocket.chat/core-typings';
|
|
import { LivechatDepartment, LivechatDepartmentAgents, LivechatVisitors, LivechatRooms, Users } from '@rocket.chat/models';
|
|
import { isDepartmentCreationAvailable } from '@rocket.chat/omni-core';
|
|
import { check, Match } from 'meteor/check';
|
|
import { Meteor } from 'meteor/meteor';
|
|
|
|
import { updateDepartmentAgents } from './Helper';
|
|
import { afterDepartmentArchived, afterDepartmentUnarchived } from './hooks';
|
|
import { livechatLogger } from './logger';
|
|
import { settings } from '../../settings';
|
|
import { callbacks } from '../callbacks';
|
|
import { notifyOnLivechatDepartmentAgentChangedByDepartmentId, notifyOnLivechatDepartmentAgentChanged } from '../notifyListener';
|
|
/**
|
|
* @param {string|null} _id - The department id
|
|
* @param {Partial<import('@rocket.chat/core-typings').ILivechatDepartment>} departmentData
|
|
* @param {{upsert?: { agentId: string; count?: number; order?: number; }[], remove?: { agentId: string; count?: number; order?: number; }}} [departmentAgents] - The department agents
|
|
* @param {{_id?: string}} [departmentUnit] - The department's unit id
|
|
*/
|
|
export async function saveDepartment(
|
|
userId: string,
|
|
_id: string | null,
|
|
departmentData: LivechatDepartmentDTO,
|
|
departmentAgents?: {
|
|
upsert?: { agentId: string; count?: number; order?: number }[];
|
|
remove?: { agentId: string; count?: number; order?: number }[];
|
|
},
|
|
departmentUnit?: { _id?: string },
|
|
) {
|
|
if (departmentUnit?._id !== undefined && typeof departmentUnit._id !== 'string') {
|
|
throw new Meteor.Error('error-invalid-department-unit', 'Invalid department unit id provided', {
|
|
method: 'livechat:saveDepartment',
|
|
});
|
|
}
|
|
|
|
const department = _id
|
|
? await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'archived' | 'enabled' | 'parentId'>>(_id, {
|
|
projection: { _id: 1, archived: 1, enabled: 1, parentId: 1 },
|
|
})
|
|
: null;
|
|
|
|
if (departmentUnit && !departmentUnit._id && department && department.parentId) {
|
|
const isLastDepartmentInUnit = (await LivechatDepartment.countDepartmentsInUnit(department.parentId)) === 1;
|
|
if (isLastDepartmentInUnit) {
|
|
throw new Meteor.Error('error-unit-cant-be-empty', "The last department in a unit can't be removed", {
|
|
method: 'livechat:saveDepartment',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!department && !(await isDepartmentCreationAvailable())) {
|
|
throw new Meteor.Error('error-max-departments-number-reached', 'Maximum number of departments reached', {
|
|
method: 'livechat:saveDepartment',
|
|
});
|
|
}
|
|
|
|
if (department?.archived && departmentData.enabled) {
|
|
throw new Meteor.Error('error-archived-department-cant-be-enabled', 'Archived departments cant be enabled', {
|
|
method: 'livechat:saveDepartment',
|
|
});
|
|
}
|
|
|
|
// TODO: Use AJV or Zod for validation (or the lib we are using rn)
|
|
const defaultValidations: Record<string, Match.Matcher<any> | BooleanConstructor | StringConstructor> = {
|
|
enabled: Boolean,
|
|
name: String,
|
|
description: Match.Optional(String),
|
|
showOnRegistration: Boolean,
|
|
email: String,
|
|
showOnOfflineForm: Boolean,
|
|
requestTagBeforeClosingChat: Match.Optional(Boolean),
|
|
chatClosingTags: Match.Optional([String]),
|
|
fallbackForwardDepartment: Match.Optional(String),
|
|
departmentsAllowedToForward: Match.Optional([String]),
|
|
allowReceiveForwardOffline: Match.Optional(Boolean),
|
|
};
|
|
|
|
// The Livechat Form department support addition/custom fields, so those fields need to be added before validating
|
|
Object.keys(departmentData).forEach((field) => {
|
|
if (!defaultValidations.hasOwnProperty(field)) {
|
|
defaultValidations[field] = Match.OneOf(String, Match.Integer, Boolean);
|
|
}
|
|
});
|
|
|
|
check(departmentData, defaultValidations);
|
|
check(
|
|
departmentAgents,
|
|
Match.Maybe({
|
|
upsert: Match.Maybe(Array),
|
|
remove: Match.Maybe(Array),
|
|
}),
|
|
);
|
|
|
|
const { requestTagBeforeClosingChat, chatClosingTags, fallbackForwardDepartment } = departmentData;
|
|
if (requestTagBeforeClosingChat && (!chatClosingTags || chatClosingTags.length === 0)) {
|
|
throw new Meteor.Error(
|
|
'error-validating-department-chat-closing-tags',
|
|
'At least one closing tag is required when the department requires tag(s) on closing conversations.',
|
|
{ method: 'livechat:saveDepartment' },
|
|
);
|
|
}
|
|
|
|
if (_id && !department) {
|
|
throw new Meteor.Error('error-department-not-found', 'Department not found', {
|
|
method: 'livechat:saveDepartment',
|
|
});
|
|
}
|
|
|
|
if (fallbackForwardDepartment === _id) {
|
|
throw new Meteor.Error(
|
|
'error-fallback-department-circular',
|
|
'Cannot save department. Circular reference between fallback department and department',
|
|
);
|
|
}
|
|
|
|
if (fallbackForwardDepartment) {
|
|
const fallbackDep = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id'>>(fallbackForwardDepartment, {
|
|
projection: { _id: 1 },
|
|
});
|
|
if (!fallbackDep) {
|
|
throw new Meteor.Error('error-fallback-department-not-found', 'Fallback department not found', {
|
|
method: 'livechat:saveDepartment',
|
|
});
|
|
}
|
|
}
|
|
|
|
const departmentDB = await LivechatDepartment.createOrUpdateDepartment(_id, departmentData);
|
|
if (departmentDB && departmentAgents) {
|
|
await updateDepartmentAgents(departmentDB._id, departmentAgents, departmentDB.enabled);
|
|
}
|
|
|
|
if (department?.enabled !== departmentData.enabled) {
|
|
void notifyOnLivechatDepartmentAgentChangedByDepartmentId(departmentDB._id, department ? 'updated' : 'inserted');
|
|
}
|
|
|
|
// Disable event
|
|
if (department?.enabled && !departmentDB?.enabled) {
|
|
await callbacks.run('livechat.afterDepartmentDisabled', departmentDB);
|
|
void Apps.self?.triggerEvent(AppEvents.IPostLivechatDepartmentDisabled, { department: departmentDB });
|
|
}
|
|
|
|
if (departmentUnit) {
|
|
await callbacks.run('livechat.manageDepartmentUnit', { userId, departmentId: departmentDB._id, unitId: departmentUnit._id });
|
|
}
|
|
|
|
return departmentDB;
|
|
}
|
|
|
|
export async function archiveDepartment(_id: string) {
|
|
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'businessHourId'>>(_id, {
|
|
projection: { _id: 1, businessHourId: 1 },
|
|
});
|
|
|
|
if (!department) {
|
|
throw new Error('department-not-found');
|
|
}
|
|
|
|
const status = await LivechatDepartment.archiveDepartment(department._id);
|
|
if (status.modifiedCount) {
|
|
await afterDepartmentArchived(department);
|
|
}
|
|
}
|
|
|
|
export async function unarchiveDepartment(_id: string) {
|
|
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id'>>(_id, { projection: { _id: 1 } });
|
|
|
|
if (!department) {
|
|
throw new Meteor.Error('department-not-found');
|
|
}
|
|
|
|
const status = await LivechatDepartment.unarchiveDepartment(department._id);
|
|
if (status.modifiedCount) {
|
|
await afterDepartmentUnarchived(department);
|
|
}
|
|
}
|
|
|
|
export async function saveDepartmentAgents(
|
|
_id: string,
|
|
departmentAgents: {
|
|
upsert?: (Pick<ILivechatDepartmentAgents, 'agentId' | 'username'> & {
|
|
count?: number;
|
|
order?: number;
|
|
})[];
|
|
remove?: Pick<ILivechatDepartmentAgents, 'agentId' | 'username'>[];
|
|
},
|
|
) {
|
|
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'enabled'>>(_id, { projection: { enabled: 1 } });
|
|
if (!department) {
|
|
throw new Meteor.Error('error-department-not-found', 'Department not found');
|
|
}
|
|
|
|
return updateDepartmentAgents(_id, departmentAgents, department.enabled);
|
|
}
|
|
|
|
export async function setDepartmentForGuest({ visitorId, department }: { visitorId: string; department: string }) {
|
|
livechatLogger.debug({
|
|
msg: 'Switching departments for visitor',
|
|
visitorId,
|
|
department,
|
|
});
|
|
|
|
const dep = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id'>>(department, { projection: { _id: 1 } });
|
|
if (!dep) {
|
|
throw new Meteor.Error('invalid-department', 'Provided department does not exists');
|
|
}
|
|
|
|
// Visitor is already validated at this point
|
|
return LivechatVisitors.updateDepartmentById(visitorId, department);
|
|
}
|
|
|
|
export async function removeDepartment(departmentId: string) {
|
|
livechatLogger.debug({ msg: 'Removing department', departmentId });
|
|
|
|
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'businessHourId' | 'parentId'>>(departmentId, {
|
|
projection: { _id: 1, businessHourId: 1, parentId: 1 },
|
|
});
|
|
if (!department) {
|
|
throw new Error('error-department-not-found');
|
|
}
|
|
|
|
const { _id } = department;
|
|
|
|
const ret = await LivechatDepartment.removeById(_id);
|
|
if (ret.acknowledged !== true) {
|
|
throw new Error('error-failed-to-delete-department');
|
|
}
|
|
|
|
const removedAgents = await LivechatDepartmentAgents.findByDepartmentId(department._id, { projection: { agentId: 1 } }).toArray();
|
|
|
|
const actions = ['Removing department agents', 'Unsetting fallback department', 'Removing department from rooms'];
|
|
livechatLogger.debug({
|
|
msg: 'Post department removal actions',
|
|
departmentId: _id,
|
|
actions,
|
|
});
|
|
|
|
const promiseResponses = await Promise.allSettled([
|
|
LivechatDepartmentAgents.removeByDepartmentId(_id),
|
|
LivechatDepartment.unsetFallbackDepartmentByDepartmentId(_id),
|
|
LivechatRooms.bulkRemoveDepartmentAndUnitsFromRooms(_id),
|
|
]);
|
|
|
|
promiseResponses.forEach((response, index) => {
|
|
if (response.status === 'rejected') {
|
|
livechatLogger.error({
|
|
msg: 'Post removal action failed',
|
|
actionId: actions[index],
|
|
error: response.reason,
|
|
});
|
|
}
|
|
});
|
|
|
|
const { deletedCount } = promiseResponses[0].status === 'fulfilled' ? promiseResponses[0].value : { deletedCount: 0 };
|
|
|
|
if (deletedCount > 0) {
|
|
removedAgents.forEach(({ _id: docId, agentId }) => {
|
|
void notifyOnLivechatDepartmentAgentChanged(
|
|
{
|
|
_id: docId,
|
|
agentId,
|
|
departmentId: _id,
|
|
},
|
|
'removed',
|
|
);
|
|
});
|
|
}
|
|
|
|
await callbacks.run('livechat.afterRemoveDepartment', { department, agentsIds: removedAgents.map(({ agentId }) => agentId) });
|
|
void Apps.self?.triggerEvent(AppEvents.IPostLivechatDepartmentRemoved, { department });
|
|
|
|
return ret;
|
|
}
|
|
|
|
export async function getRequiredDepartment(onlineRequired = true) {
|
|
if (!onlineRequired) {
|
|
return LivechatDepartment.findOneEnabledWithAgentsAndRegistration();
|
|
}
|
|
|
|
const departments = LivechatDepartment.findEnabledWithAgentsAndRegistration();
|
|
|
|
for await (const dept of departments) {
|
|
const onlineAgents = await checkOnlineForDepartment(dept._id);
|
|
if (onlineAgents) {
|
|
return dept;
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function checkOnlineForDepartment(departmentId: string) {
|
|
const depUsers = await LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray();
|
|
const onlineForDep = await Users.findOneOnlineAgentByUserList(
|
|
depUsers.map((agent) => agent.username),
|
|
{ projection: { _id: 1 } },
|
|
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
|
|
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
|
|
);
|
|
|
|
return !!onlineForDep;
|
|
}
|
|
|
|
export async function getOnlineForDepartment(departmentId: string) {
|
|
const agents = await LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray();
|
|
const usernames = agents.map(({ username }) => username);
|
|
|
|
return Users.findOnlineUserFromList<ILivechatAgent>(
|
|
[...new Set(usernames)],
|
|
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
|
|
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
|
|
);
|
|
}
|