snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
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
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
This commit is contained in:
170
apps/meteor/server/api/v1/omnichannel/agent.ts
Normal file
170
apps/meteor/server/api/v1/omnichannel/agent.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { ILivechatAgent } from '@rocket.chat/core-typings';
|
||||
import { ILivechatAgentStatus } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import {
|
||||
isGETAgentNextToken,
|
||||
isPOSTLivechatAgentSaveInfoParams,
|
||||
isPOSTLivechatAgentStatusProps,
|
||||
POSTLivechatAgentSaveInfoSuccessResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findRoom, findGuest, findAgent, findOpenRoom } from './lib/livechat';
|
||||
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { hasRoleAsync } from '../../../lib/authorization/hasRole';
|
||||
import { RoutingManager } from '../../../lib/omnichannel/RoutingManager';
|
||||
import { getRequiredDepartment } from '../../../lib/omnichannel/departmentsLib';
|
||||
import { saveAgentInfo } from '../../../lib/omnichannel/omni-users';
|
||||
import { setUserStatusLivechat, allowAgentChangeServiceStatus } from '../../../lib/omnichannel/utils';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
|
||||
API.v1.addRoute('livechat/agent.info/:rid/:token', {
|
||||
async get() {
|
||||
const visitor = await findGuest(this.urlParams.token);
|
||||
if (!visitor) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(this.urlParams.token, this.urlParams.rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
const agent = room?.servedBy && (await findAgent(room.servedBy._id));
|
||||
if (!agent) {
|
||||
throw new Error('invalid-agent');
|
||||
}
|
||||
|
||||
return API.v1.success({ agent });
|
||||
},
|
||||
});
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/agent.next/:token',
|
||||
{ validateParams: isGETAgentNextToken },
|
||||
{
|
||||
async get() {
|
||||
const { token } = this.urlParams;
|
||||
const room = await findOpenRoom(token, undefined, this.userId);
|
||||
if (room) {
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
let { department } = this.queryParams;
|
||||
if (!department) {
|
||||
const requireDepartment = await getRequiredDepartment();
|
||||
if (requireDepartment) {
|
||||
department = requireDepartment._id;
|
||||
}
|
||||
}
|
||||
|
||||
const agentData = await RoutingManager.getNextAgent(department);
|
||||
if (!agentData) {
|
||||
throw new Error('agent-not-found');
|
||||
}
|
||||
|
||||
const agent = await findAgent(agentData.agentId);
|
||||
if (!agent) {
|
||||
throw new Error('invalid-agent');
|
||||
}
|
||||
|
||||
return API.v1.success({ agent });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/agent.status',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isPOSTLivechatAgentStatusProps },
|
||||
{
|
||||
async post() {
|
||||
const { status, agentId: inputAgentId } = this.bodyParams;
|
||||
|
||||
const agentId = inputAgentId || this.userId;
|
||||
|
||||
const agent = await Users.findOneAgentById<Pick<ILivechatAgent, 'status' | 'statusLivechat' | 'active'>>(agentId, {
|
||||
projection: {
|
||||
status: 1,
|
||||
statusLivechat: 1,
|
||||
active: 1,
|
||||
},
|
||||
});
|
||||
if (!agent) {
|
||||
return API.v1.notFound('Agent not found');
|
||||
}
|
||||
|
||||
if (!agent.active) {
|
||||
return API.v1.failure('error-user-deactivated');
|
||||
}
|
||||
|
||||
const newStatus: ILivechatAgentStatus =
|
||||
status ||
|
||||
(agent.statusLivechat === ILivechatAgentStatus.AVAILABLE ? ILivechatAgentStatus.NOT_AVAILABLE : ILivechatAgentStatus.AVAILABLE);
|
||||
if (newStatus === agent.statusLivechat) {
|
||||
return API.v1.success({ status: agent.statusLivechat });
|
||||
}
|
||||
|
||||
const canChangeStatus = await allowAgentChangeServiceStatus(newStatus, agentId);
|
||||
|
||||
if (agentId !== this.userId) {
|
||||
if (!(await hasPermissionAsync(this.user, 'manage-livechat-agents'))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
// Silent fail for admins when BH is closed
|
||||
// Next version we'll update this to return an error
|
||||
// And update the FE accordingly
|
||||
if (canChangeStatus) {
|
||||
await setUserStatusLivechat(agentId, newStatus);
|
||||
return API.v1.success({ status: newStatus });
|
||||
}
|
||||
|
||||
return API.v1.success({ status: agent.statusLivechat });
|
||||
}
|
||||
|
||||
if (!canChangeStatus) {
|
||||
return API.v1.failure('error-business-hours-are-closed');
|
||||
}
|
||||
|
||||
await setUserStatusLivechat(agentId, newStatus);
|
||||
|
||||
return API.v1.success({ status: newStatus });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatAgentsEndpoints = API.v1.post(
|
||||
'livechat/agents.saveInfo',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatAgentSaveInfoSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-agents'],
|
||||
body: isPOSTLivechatAgentSaveInfoParams,
|
||||
},
|
||||
async function action() {
|
||||
const { agentId, agentData, agentDepartments } = this.bodyParams;
|
||||
|
||||
if (!(await hasRoleAsync(agentId, 'livechat-agent'))) {
|
||||
return API.v1.failure('error-user-is-not-agent');
|
||||
}
|
||||
|
||||
await saveAgentInfo(agentId, agentData, agentDepartments);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatAgentsEndpoints = ExtractRoutesFromAPI<typeof livechatAgentsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatAgentsEndpoints {}
|
||||
}
|
||||
19
apps/meteor/server/api/v1/omnichannel/agentDepartments.ts
Normal file
19
apps/meteor/server/api/v1/omnichannel/agentDepartments.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { isGETLivechatAgentsAgentIdDepartmentsParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findAgentDepartments } from './lib/agents';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/agents/:agentId/departments',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatAgentsAgentIdDepartmentsParams },
|
||||
{
|
||||
async get() {
|
||||
const departments = await findAgentDepartments({
|
||||
enabledDepartmentsOnly: this.queryParams.enabledDepartmentsOnly && this.queryParams.enabledDepartmentsOnly === 'true',
|
||||
agentId: this.urlParams.agentId,
|
||||
});
|
||||
|
||||
return API.v1.success(departments);
|
||||
},
|
||||
},
|
||||
);
|
||||
140
apps/meteor/server/api/v1/omnichannel/appearance.ts
Normal file
140
apps/meteor/server/api/v1/omnichannel/appearance.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ISettingSelectOption } from '@rocket.chat/core-typings';
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
import { isPOSTLivechatAppearanceParams } from '@rocket.chat/rest-typings';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findAppearance } from './lib/appearance';
|
||||
import { notifyOnSettingChangedById } from '../../../lib/notifyListener';
|
||||
import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/appearance',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: {
|
||||
POST: isPOSTLivechatAppearanceParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { appearance } = await findAppearance();
|
||||
|
||||
return API.v1.success({
|
||||
appearance,
|
||||
});
|
||||
},
|
||||
async post() {
|
||||
const settings = this.bodyParams;
|
||||
|
||||
const validSettingList = [
|
||||
'Livechat_title',
|
||||
'Livechat_title_color',
|
||||
'Livechat_enable_message_character_limit',
|
||||
'Livechat_message_character_limit',
|
||||
'Livechat_show_agent_info',
|
||||
'Livechat_show_agent_email',
|
||||
'Livechat_display_offline_form',
|
||||
'Livechat_offline_form_unavailable',
|
||||
'Livechat_offline_message',
|
||||
'Livechat_offline_success_message',
|
||||
'Livechat_offline_title',
|
||||
'Livechat_offline_title_color',
|
||||
'Livechat_offline_email',
|
||||
'Livechat_conversation_finished_message',
|
||||
'Livechat_conversation_finished_text',
|
||||
'Livechat_registration_form',
|
||||
'Livechat_name_field_registration_form',
|
||||
'Livechat_email_field_registration_form',
|
||||
'Livechat_registration_form_message',
|
||||
'Livechat_hide_watermark',
|
||||
'Livechat_background',
|
||||
'Livechat_widget_position',
|
||||
'Livechat_hide_system_messages',
|
||||
'Omnichannel_allow_visitors_to_close_conversation',
|
||||
'Livechat_hide_expand_chat',
|
||||
];
|
||||
|
||||
const valid = settings.every((setting) => validSettingList.includes(setting._id));
|
||||
|
||||
if (!valid) {
|
||||
throw new Error('invalid-setting');
|
||||
}
|
||||
|
||||
const dbSettings = await Settings.findByIds(validSettingList, { projection: { _id: 1, value: 1, type: 1, values: 1 } })
|
||||
.map((dbSetting) => {
|
||||
const setting = settings.find(({ _id }) => _id === dbSetting._id);
|
||||
if (!setting || dbSetting.value === setting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbSetting.type === 'multiSelect' && (!Array.isArray(setting.value) || !validateValues(setting.value, dbSetting.values))) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (dbSetting?.type) {
|
||||
case 'boolean':
|
||||
return {
|
||||
_id: dbSetting._id,
|
||||
value: setting.value === 'true' || setting.value === true,
|
||||
};
|
||||
case 'int':
|
||||
return {
|
||||
_id: dbSetting._id,
|
||||
value: coerceInt(setting.value),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
_id: dbSetting._id,
|
||||
value: setting?.value,
|
||||
};
|
||||
}
|
||||
})
|
||||
.toArray();
|
||||
|
||||
const eligibleSettings = dbSettings.filter(isTruthy);
|
||||
|
||||
const auditSettingOperation = updateAuditedByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username,
|
||||
ip: this.requestIp,
|
||||
useragent: this.request.headers.get('user-agent') || '',
|
||||
});
|
||||
|
||||
const promises = eligibleSettings.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value));
|
||||
(await Promise.all(promises)).forEach((value, index) => {
|
||||
if (value?.modifiedCount) {
|
||||
void notifyOnSettingChangedById(eligibleSettings[index]._id);
|
||||
}
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function validateValues(values: string[], allowedValues: ISettingSelectOption[] = []): boolean {
|
||||
return values.every((value) => allowedValues.some((allowedValue) => allowedValue.key === value));
|
||||
}
|
||||
|
||||
function coerceInt(value: string | number | boolean | string[]): number {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsedValue = parseInt(value, 10);
|
||||
if (Number.isNaN(parsedValue)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
}
|
||||
77
apps/meteor/server/api/v1/omnichannel/businessHours.ts
Normal file
77
apps/meteor/server/api/v1/omnichannel/businessHours.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
isGETBusinessHourParams,
|
||||
isPOSTLivechatBusinessHoursSaveParams,
|
||||
isPOSTLivechatBusinessHoursRemoveParams,
|
||||
POSTLivechatBusinessHoursRemoveSuccessResponse,
|
||||
POSTLivechatBusinessHoursSaveSuccessResponse,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { businessHourManager } from '../../../lib/omnichannel/business-hour';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { findLivechatBusinessHour } from './lib/businessHours';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/business-hour',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-business-hours'], validateParams: isGETBusinessHourParams },
|
||||
{
|
||||
async get() {
|
||||
const { _id, type } = this.queryParams;
|
||||
const { businessHour } = await findLivechatBusinessHour(_id, type);
|
||||
return API.v1.success({
|
||||
businessHour,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatBusinessHoursEndpoints = API.v1
|
||||
.post(
|
||||
'livechat/business-hours.save',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatBusinessHoursSaveSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
body: isPOSTLivechatBusinessHoursSaveParams,
|
||||
},
|
||||
async function action() {
|
||||
const params = this.bodyParams;
|
||||
|
||||
// TODO: Remove typecasting after refactoring saveBusinessHour logic with proper type logic. See: CORE-1552
|
||||
const result = await businessHourManager.saveBusinessHour(params as unknown as ILivechatBusinessHour);
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'livechat/business-hours.remove',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatBusinessHoursRemoveSuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
body: isPOSTLivechatBusinessHoursRemoveParams,
|
||||
},
|
||||
async function action() {
|
||||
const { _id, type } = this.bodyParams;
|
||||
|
||||
await businessHourManager.removeBusinessHourByIdAndType(_id, type);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatBusinessHoursEndpoints = ExtractRoutesFromAPI<typeof livechatBusinessHoursEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatBusinessHoursEndpoints {}
|
||||
}
|
||||
67
apps/meteor/server/api/v1/omnichannel/config.ts
Normal file
67
apps/meteor/server/api/v1/omnichannel/config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { GETLivechatConfigRouting, isGETLivechatConfigParams } from '@rocket.chat/rest-typings';
|
||||
import mem from 'mem';
|
||||
|
||||
import { API } from '../..';
|
||||
import { settings, findOpenRoom, getExtraConfigInfo, findAgent, findGuestWithoutActivity } from './lib/livechat';
|
||||
import { RoutingManager } from '../../../lib/omnichannel/RoutingManager';
|
||||
import { online } from '../../../lib/omnichannel/service-status';
|
||||
import { settings as serverSettings } from '../../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
|
||||
const cachedSettings = mem(settings, {
|
||||
maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 1000,
|
||||
cacheKey: JSON.stringify,
|
||||
});
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/config',
|
||||
{ validateParams: isGETLivechatConfigParams },
|
||||
{
|
||||
async get() {
|
||||
const enabled = serverSettings.get<boolean>('Livechat_enabled');
|
||||
|
||||
if (!enabled) {
|
||||
return API.v1.success({ config: { enabled: false } });
|
||||
}
|
||||
|
||||
const { token, department, businessUnit } = this.queryParams;
|
||||
const [config, status, guest] = await Promise.all([
|
||||
cachedSettings({ businessUnit }),
|
||||
online(department),
|
||||
token ? findGuestWithoutActivity(token) : null,
|
||||
]);
|
||||
|
||||
const room = guest ? await findOpenRoom(guest.token, undefined, this.userId) : undefined;
|
||||
const agentPromise = room?.servedBy ? findAgent(room.servedBy._id) : null;
|
||||
const extraInfoPromise = getExtraConfigInfo({ room });
|
||||
|
||||
const [agent, extraInfo] = await Promise.all([agentPromise, extraInfoPromise]);
|
||||
|
||||
return API.v1.success({
|
||||
config: { ...config, online: status, ...extraInfo, ...(guest && { guest }), ...(room && { room }), ...(agent && { agent }) },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatConfigEndpoints = API.v1.get(
|
||||
'livechat/config/routing',
|
||||
{
|
||||
response: {
|
||||
200: GETLivechatConfigRouting,
|
||||
},
|
||||
authRequired: true,
|
||||
},
|
||||
async function action() {
|
||||
const config = RoutingManager.getConfig();
|
||||
|
||||
return API.v1.success({ config });
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatConfigEndpoints = ExtractRoutesFromAPI<typeof livechatConfigEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatConfigEndpoints {}
|
||||
}
|
||||
268
apps/meteor/server/api/v1/omnichannel/contact.ts
Normal file
268
apps/meteor/server/api/v1/omnichannel/contact.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { LivechatContacts, LivechatCustomField, LivechatVisitors } from '@rocket.chat/models';
|
||||
import {
|
||||
isPOSTOmnichannelContactsProps,
|
||||
isPOSTUpdateOmnichannelContactsProps,
|
||||
isGETOmnichannelContactsProps,
|
||||
isGETOmnichannelContactHistoryProps,
|
||||
isGETOmnichannelContactsChannelsProps,
|
||||
isGETOmnichannelContactsSearchProps,
|
||||
isGETOmnichannelContactsCheckExistenceProps,
|
||||
isPOSTOmnichannelContactsConflictsProps,
|
||||
isPOSTOmnichannelContactDeleteProps,
|
||||
POSTOmnichannelContactDeleteSuccessSchema,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp, removeEmpty } from '@rocket.chat/tools';
|
||||
import { Match, check } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { createContact } from '../../../lib/omnichannel/contacts/createContact';
|
||||
import { disableContactById } from '../../../lib/omnichannel/contacts/disableContact';
|
||||
import { getContactChannelsGrouped } from '../../../lib/omnichannel/contacts/getContactChannelsGrouped';
|
||||
import { getContactHistory } from '../../../lib/omnichannel/contacts/getContactHistory';
|
||||
import { getContacts } from '../../../lib/omnichannel/contacts/getContacts';
|
||||
import { registerContact } from '../../../lib/omnichannel/contacts/registerContact';
|
||||
import { resolveContactConflicts } from '../../../lib/omnichannel/contacts/resolveContactConflicts';
|
||||
import { updateContact } from '../../../lib/omnichannel/contacts/updateContact';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contact',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
check(this.bodyParams, {
|
||||
_id: Match.Maybe(String),
|
||||
token: String,
|
||||
name: String,
|
||||
email: Match.Maybe(String),
|
||||
phone: Match.Maybe(String),
|
||||
username: Match.Maybe(String),
|
||||
customFields: Match.Maybe(Object),
|
||||
contactManager: Match.Maybe({
|
||||
username: String,
|
||||
}),
|
||||
});
|
||||
|
||||
const contact = await registerContact(this.bodyParams, this.userId);
|
||||
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
async get() {
|
||||
check(this.queryParams, {
|
||||
contactId: String,
|
||||
});
|
||||
|
||||
const contact = await LivechatVisitors.findOneEnabledById(this.queryParams.contactId);
|
||||
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contact.search',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
check(this.queryParams, {
|
||||
email: Match.Maybe(String),
|
||||
phone: Match.Maybe(String),
|
||||
custom: Match.Maybe(String),
|
||||
});
|
||||
const { email, phone, custom } = this.queryParams;
|
||||
|
||||
let customCF: { [k: string]: string } = {};
|
||||
try {
|
||||
customCF = custom && JSON.parse(custom);
|
||||
} catch (e) {
|
||||
throw new Meteor.Error('error-invalid-params-custom');
|
||||
}
|
||||
|
||||
if (!email && !phone && !Object.keys(customCF).length) {
|
||||
throw new Meteor.Error('error-invalid-params');
|
||||
}
|
||||
|
||||
const foundCF = await (async () => {
|
||||
if (!custom) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const cfIds = Object.keys(customCF);
|
||||
|
||||
const customFields = await LivechatCustomField.findMatchingCustomFieldsByIds(cfIds, 'visitor', true, {
|
||||
projection: { _id: 1 },
|
||||
}).toArray();
|
||||
|
||||
return Object.fromEntries(customFields.map(({ _id }) => [`livechatData.${_id}`, new RegExp(escapeRegExp(customCF[_id]), 'i')]));
|
||||
})();
|
||||
|
||||
const contact = await LivechatVisitors.findOneByEmailAndPhoneAndCustomField(email, phone, foundCF);
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts',
|
||||
{ authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps },
|
||||
{
|
||||
async post() {
|
||||
const contactId = await createContact({ ...this.bodyParams, unknown: false });
|
||||
|
||||
return API.v1.success({ contactId });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.update',
|
||||
{ authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTUpdateOmnichannelContactsProps },
|
||||
{
|
||||
async post() {
|
||||
const contact = await updateContact(removeEmpty(this.bodyParams));
|
||||
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.conflicts',
|
||||
{ authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTOmnichannelContactsConflictsProps },
|
||||
{
|
||||
async post() {
|
||||
const result = await resolveContactConflicts(removeEmpty(this.bodyParams));
|
||||
|
||||
return API.v1.success({ result });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.get',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsProps },
|
||||
{
|
||||
async get() {
|
||||
const { contactId } = this.queryParams;
|
||||
|
||||
if (!contactId) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
const contact = await LivechatContacts.findOneEnabledById(contactId);
|
||||
|
||||
if (!contact) {
|
||||
return API.v1.notFound();
|
||||
}
|
||||
|
||||
return API.v1.success({ contact });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.search',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsSearchProps },
|
||||
{
|
||||
async get() {
|
||||
const query = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const result = await getContacts({ ...query, offset, count, sort });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.checkExistence',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsCheckExistenceProps },
|
||||
{
|
||||
async get() {
|
||||
const { contactId, email, phone } = this.queryParams;
|
||||
|
||||
const contact = await LivechatContacts.countByContactInfo({ contactId, email, phone });
|
||||
|
||||
return API.v1.success({ exists: contact > 0 });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.history',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact-history'], validateParams: isGETOmnichannelContactHistoryProps },
|
||||
{
|
||||
async get() {
|
||||
const { contactId, source } = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const history = await getContactHistory({ contactId, source, count, offset, sort });
|
||||
|
||||
return API.v1.success(history);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/contacts.channels',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsChannelsProps },
|
||||
{
|
||||
async get() {
|
||||
const { contactId } = this.queryParams;
|
||||
|
||||
const channels = await getContactChannelsGrouped(contactId);
|
||||
|
||||
return API.v1.success({ channels });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const omnichannelContactsEndpoints = API.v1.post(
|
||||
'omnichannel/contacts.delete',
|
||||
{
|
||||
response: {
|
||||
200: POSTOmnichannelContactDeleteSuccessSchema,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['delete-livechat-contact'],
|
||||
body: isPOSTOmnichannelContactDeleteProps,
|
||||
},
|
||||
async function action() {
|
||||
const { contactId } = this.bodyParams;
|
||||
|
||||
try {
|
||||
await disableContactById(contactId);
|
||||
return API.v1.success();
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return API.v1.failure('error-invalid-contact');
|
||||
}
|
||||
|
||||
return API.v1.failure(error.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type OmnichannelContactsEndpoints = ExtractRoutesFromAPI<typeof omnichannelContactsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends OmnichannelContactsEndpoints {}
|
||||
}
|
||||
166
apps/meteor/server/api/v1/omnichannel/customField.ts
Normal file
166
apps/meteor/server/api/v1/omnichannel/customField.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { LivechatCustomField } from '@rocket.chat/models';
|
||||
import {
|
||||
isLivechatCustomFieldsProps,
|
||||
isPOSTLivechatCustomFieldParams,
|
||||
isPOSTLivechatCustomFieldsParams,
|
||||
isPOSTLivechatRemoveCustomFields,
|
||||
isPOSTLivechatSaveCustomFieldsParams,
|
||||
POSTLivechatRemoveCustomFieldSuccess,
|
||||
POSTLivechatSaveCustomFieldSuccess,
|
||||
validateBadRequestErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { setCustomFields, setMultipleCustomFields } from '../../../lib/omnichannel/custom-fields';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { findLivechatCustomFields, findCustomFieldById } from './lib/customFields';
|
||||
import { findGuest } from './lib/livechat';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/custom.field',
|
||||
{ validateParams: isPOSTLivechatCustomFieldParams },
|
||||
{
|
||||
async post() {
|
||||
const { token, key, value, overwrite } = this.bodyParams;
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
await setCustomFields({ token, key, value, overwrite });
|
||||
|
||||
return API.v1.success({ field: { key, value, overwrite } });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/custom.fields',
|
||||
{ validateParams: isPOSTLivechatCustomFieldsParams },
|
||||
{
|
||||
async post() {
|
||||
const { token } = this.bodyParams;
|
||||
const visitor = await findGuest(token);
|
||||
if (!visitor) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const result = await setMultipleCustomFields({ visitor, customFields: this.bodyParams.customFields });
|
||||
|
||||
return API.v1.success({
|
||||
fields: result.map(({ key, value, overwrite }) => ({ Key: key, value, overwrite })),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/custom-fields',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatCustomFieldsProps },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { text } = this.queryParams;
|
||||
|
||||
const customFields = await findLivechatCustomFields({
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(customFields);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/custom-fields/:_id',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'] },
|
||||
{
|
||||
async get() {
|
||||
const { customField } = await findCustomFieldById({ customFieldId: this.urlParams._id });
|
||||
|
||||
return API.v1.success({
|
||||
customField,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatCustomFieldsEndpoints = API.v1
|
||||
.post(
|
||||
'livechat/custom-fields.save',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatSaveCustomFieldSuccess,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'], // is this permission appropriate for the targeted action?
|
||||
body: isPOSTLivechatSaveCustomFieldsParams,
|
||||
},
|
||||
async function action() {
|
||||
const { customFieldId, customFieldData } = this.bodyParams;
|
||||
|
||||
if (customFieldId) {
|
||||
const customField = await LivechatCustomField.findOneById(customFieldId);
|
||||
if (!customField) {
|
||||
return API.v1.failure('Custom Field Not found');
|
||||
}
|
||||
}
|
||||
|
||||
if (!customFieldId) {
|
||||
const customField = await LivechatCustomField.findOneById(customFieldData.field);
|
||||
if (customField) {
|
||||
return API.v1.failure('Custom Field already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const { field, label, scope, visibility, ...extraData } = customFieldData;
|
||||
const result = await LivechatCustomField.createOrUpdateCustomField(customFieldId, field, label, scope, visibility, {
|
||||
...extraData,
|
||||
});
|
||||
|
||||
return API.v1.success({ customField: result });
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'livechat/custom-fields.delete',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatRemoveCustomFieldSuccess,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'], // is this permission appropriate for the targeted action?
|
||||
body: isPOSTLivechatRemoveCustomFields,
|
||||
},
|
||||
async function action() {
|
||||
const { customFieldId } = this.bodyParams;
|
||||
|
||||
const result = await LivechatCustomField.removeById(customFieldId);
|
||||
if (result.deletedCount === 0) {
|
||||
return API.v1.failure('Custom field not found');
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatCustomFieldsEndpoints = ExtractRoutesFromAPI<typeof livechatCustomFieldsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatCustomFieldsEndpoints {}
|
||||
}
|
||||
296
apps/meteor/server/api/v1/omnichannel/dashboards.ts
Normal file
296
apps/meteor/server/api/v1/omnichannel/dashboards.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { OmnichannelAnalytics } from '@rocket.chat/core-services';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import {
|
||||
isGETDashboardTotalizerParams,
|
||||
isGETDashboardsAgentStatusParams,
|
||||
isGETLivechatAnalyticsDashboardsChartDataParams,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
GETLivechatAnalyticsDashboardsChartDataSuccessSchema,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import {
|
||||
getProductivityMetricsAsyncCached,
|
||||
getConversationsMetricsAsyncCached,
|
||||
getAgentsProductivityMetricsAsyncCached,
|
||||
getChatsMetricsAsyncCached,
|
||||
findAllChatsStatusAsyncCached,
|
||||
findAllChatMetricsByAgentAsyncCached,
|
||||
findAllAgentsStatusAsyncCached,
|
||||
findAllChatMetricsByDepartmentAsyncCached,
|
||||
findAllResponseTimeMetricsAsyncCached,
|
||||
} from '../../../lib/omnichannel/analytics/dashboards';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/conversation-totalizers',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isGETDashboardTotalizerParams,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } });
|
||||
if (!user) {
|
||||
return API.v1.failure('User not found');
|
||||
}
|
||||
|
||||
const totalizers = await getConversationsMetricsAsyncCached({ start, end, departmentId, user });
|
||||
return API.v1.success(totalizers);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/agents-productivity-totalizers',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } });
|
||||
if (!user) {
|
||||
return API.v1.failure('User not found');
|
||||
}
|
||||
|
||||
const totalizers = await getAgentsProductivityMetricsAsyncCached({ start, end, departmentId, user });
|
||||
return API.v1.success(totalizers);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/chats-totalizers',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
|
||||
const totalizers = await getChatsMetricsAsyncCached({ start: startDate, end: endDate, departmentId });
|
||||
return API.v1.success(totalizers);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/productivity-totalizers',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { utcOffset: 1, language: 1 } });
|
||||
if (!user) {
|
||||
return API.v1.failure('User not found');
|
||||
}
|
||||
|
||||
const totalizers = await getProductivityMetricsAsyncCached({ start, end, departmentId, user });
|
||||
|
||||
return API.v1.success(totalizers);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/chats',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
|
||||
const result = await findAllChatsStatusAsyncCached({ start: startDate, end: endDate, departmentId });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/chats-per-agent',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
const result = await findAllChatMetricsByAgentAsyncCached({ start: startDate, end: endDate, departmentId });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/agents-status',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardsAgentStatusParams },
|
||||
{
|
||||
async get() {
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
const result = await findAllAgentsStatusAsyncCached({ departmentId });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/chats-per-department',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
|
||||
const result = (await findAllChatMetricsByDepartmentAsyncCached({ start: startDate, end: endDate, departmentId })) as {
|
||||
[k: string]: { open: number; closed: number };
|
||||
};
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/dashboards/charts/timings',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETDashboardTotalizerParams },
|
||||
{
|
||||
async get() {
|
||||
const { start, end } = this.queryParams;
|
||||
const { departmentId } = this.queryParams;
|
||||
|
||||
if (isNaN(Date.parse(start))) {
|
||||
return API.v1.failure('The "start" query parameter must be a valid date.');
|
||||
}
|
||||
const startDate = new Date(start);
|
||||
|
||||
if (isNaN(Date.parse(end))) {
|
||||
return API.v1.failure('The "end" query parameter must be a valid date.');
|
||||
}
|
||||
const endDate = new Date(end);
|
||||
|
||||
const result = await findAllResponseTimeMetricsAsyncCached({ start: startDate, end: endDate, departmentId });
|
||||
|
||||
return API.v1.success(result);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatAnalyticsEndpoints = API.v1.get(
|
||||
'livechat/analytics/dashboards/charts-data',
|
||||
{
|
||||
response: {
|
||||
200: GETLivechatAnalyticsDashboardsChartDataSuccessSchema,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
query: isGETLivechatAnalyticsDashboardsChartDataParams,
|
||||
},
|
||||
async function action() {
|
||||
const { chartName, start, end, departmentId } = this.queryParams;
|
||||
|
||||
const chartData = await OmnichannelAnalytics.getAnalyticsChartData({
|
||||
daterange: {
|
||||
from: start,
|
||||
to: end,
|
||||
},
|
||||
chartOptions: {
|
||||
name: chartName,
|
||||
},
|
||||
utcOffset: this.user.utcOffset,
|
||||
executedBy: this.user._id,
|
||||
departmentId,
|
||||
});
|
||||
|
||||
if (!chartData) {
|
||||
return API.v1.success({
|
||||
chartLabel: chartName,
|
||||
dataLabels: [],
|
||||
dataPoints: [],
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.success(chartData);
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatAnalyticsEndpoints = ExtractRoutesFromAPI<typeof livechatAnalyticsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatAnalyticsEndpoints {}
|
||||
}
|
||||
333
apps/meteor/server/api/v1/omnichannel/departments.ts
Normal file
333
apps/meteor/server/api/v1/omnichannel/departments.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models';
|
||||
import { isDepartmentCreationAvailable } from '@rocket.chat/omni-core';
|
||||
import { isGETLivechatDepartmentProps, isPOSTLivechatDepartmentProps } from '@rocket.chat/rest-typings';
|
||||
import {
|
||||
isLivechatDepartmentDepartmentIdAgentsGETProps,
|
||||
isLivechatDepartmentDepartmentIdAgentsPOSTProps,
|
||||
} from '@rocket.chat/rest-typings/src/v1/omnichannel';
|
||||
import { Match, check } from 'meteor/check';
|
||||
|
||||
import { API } from '../..';
|
||||
import {
|
||||
findDepartments,
|
||||
findDepartmentById,
|
||||
findDepartmentsToAutocomplete,
|
||||
findDepartmentsBetweenIds,
|
||||
findDepartmentAgents,
|
||||
findArchivedDepartments,
|
||||
} from './lib/departments';
|
||||
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { apiDeprecationLogger } from '../../../lib/deprecationWarningLogger';
|
||||
import {
|
||||
saveDepartment,
|
||||
archiveDepartment,
|
||||
unarchiveDepartment,
|
||||
saveDepartmentAgents,
|
||||
removeDepartment,
|
||||
} from '../../../lib/omnichannel/departmentsLib';
|
||||
import { settings } from '../../../settings';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department',
|
||||
{
|
||||
authRequired: true,
|
||||
validateParams: { GET: isGETLivechatDepartmentProps, POST: isPOSTLivechatDepartmentProps },
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
|
||||
POST: { permissions: ['manage-livechat-departments'], operation: 'hasAll' },
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const { text, enabled, onlyMyDepartments, excludeDepartmentId, showArchived } = this.queryParams;
|
||||
|
||||
const { departments, total } = await findDepartments({
|
||||
userId: this.userId,
|
||||
text,
|
||||
enabled: enabled === 'true',
|
||||
showArchived: showArchived === 'true',
|
||||
onlyMyDepartments: onlyMyDepartments === 'true',
|
||||
excludeDepartmentId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
// IMO, sort type shouldn't be record, but a generic of the model we're trying to sort
|
||||
// or the form { [k: keyof T]: number | string }
|
||||
sort: sort as any,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success({ departments, count: departments.length, offset, total });
|
||||
},
|
||||
async post() {
|
||||
check(this.bodyParams, {
|
||||
department: Object,
|
||||
agents: Match.Maybe(Array),
|
||||
departmentUnit: Match.Maybe({ _id: Match.Optional(String) }),
|
||||
});
|
||||
|
||||
const agents = this.bodyParams.agents ? { upsert: this.bodyParams.agents } : {};
|
||||
const { departmentUnit } = this.bodyParams;
|
||||
const department = await saveDepartment(
|
||||
this.userId,
|
||||
null,
|
||||
this.bodyParams.department as ILivechatDepartment,
|
||||
agents,
|
||||
departmentUnit || {},
|
||||
);
|
||||
|
||||
if (department) {
|
||||
return API.v1.success({
|
||||
department,
|
||||
agents: await LivechatDepartmentAgents.find({ departmentId: department._id }).toArray(),
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/isDepartmentCreationAvailable',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'manage-livechat-departments'], operation: 'hasAny' },
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const available = await isDepartmentCreationAvailable();
|
||||
return API.v1.success({ isDepartmentCreationAvailable: available });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/:_id',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
|
||||
PUT: { permissions: ['manage-livechat-departments', 'add-livechat-department-agents'], operation: 'hasAny' },
|
||||
DELETE: { permissions: ['manage-livechat-departments', 'remove-livechat-department'], operation: 'hasAny' },
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
check(this.urlParams, {
|
||||
_id: String,
|
||||
});
|
||||
|
||||
const { onlyMyDepartments } = this.queryParams;
|
||||
|
||||
const { department, agents } = await findDepartmentById({
|
||||
userId: this.userId,
|
||||
departmentId: this.urlParams._id,
|
||||
includeAgents: this.queryParams.includeAgents && this.queryParams.includeAgents === 'true',
|
||||
onlyMyDepartments: onlyMyDepartments === 'true',
|
||||
});
|
||||
|
||||
// TODO: return 404 when department is not found
|
||||
// Currently, FE relies on the fact that this endpoint returns an empty payload
|
||||
// to show the "new" view. Returning 404 breaks it
|
||||
|
||||
return API.v1.success({ department, agents });
|
||||
},
|
||||
async put() {
|
||||
const permissionToSave = await hasPermissionAsync(this.user, 'manage-livechat-departments');
|
||||
const permissionToAddAgents = await hasPermissionAsync(this.user, 'add-livechat-department-agents');
|
||||
|
||||
check(this.bodyParams, {
|
||||
department: Object,
|
||||
agents: Match.Maybe(Array),
|
||||
departmentUnit: Match.Maybe({ _id: Match.Optional(String) }),
|
||||
});
|
||||
|
||||
const { _id } = this.urlParams;
|
||||
const { department, agents, departmentUnit } = this.bodyParams;
|
||||
|
||||
if (!permissionToSave) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
const agentParam = permissionToAddAgents && agents ? { upsert: agents } : {};
|
||||
await saveDepartment(this.userId, _id, department, agentParam, departmentUnit || {});
|
||||
|
||||
return API.v1.success({
|
||||
department: await LivechatDepartment.findOneById(_id),
|
||||
agents: await LivechatDepartmentAgents.findByDepartmentId(_id).toArray(),
|
||||
});
|
||||
},
|
||||
async delete() {
|
||||
check(this.urlParams, {
|
||||
_id: String,
|
||||
});
|
||||
|
||||
const isRemoveEnabled = settings.get<boolean>('Omnichannel_enable_department_removal');
|
||||
|
||||
if (!isRemoveEnabled) {
|
||||
return API.v1.failure('error-department-removal-disabled');
|
||||
}
|
||||
|
||||
await removeDepartment(this.urlParams._id);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/departments/archived',
|
||||
{
|
||||
authRequired: true,
|
||||
validateParams: { GET: isGETLivechatDepartmentProps },
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const { text, onlyMyDepartments, excludeDepartmentId } = this.queryParams;
|
||||
|
||||
const { departments, total } = await findArchivedDepartments({
|
||||
userId: this.userId,
|
||||
text,
|
||||
onlyMyDepartments: onlyMyDepartments === 'true',
|
||||
excludeDepartmentId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort: sort as any,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success({ departments, count: departments.length, offset, total });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/:_id/archive',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-departments'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
await archiveDepartment(this.urlParams._id);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/:_id/unarchive',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['manage-livechat-departments'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
await unarchiveDepartment(this.urlParams._id);
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department.autocomplete',
|
||||
{ authRequired: true, permissionsRequired: { GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' } } },
|
||||
{
|
||||
async get() {
|
||||
const { selector, onlyMyDepartments, showArchived } = this.queryParams;
|
||||
if (!selector) {
|
||||
return API.v1.failure("The 'selector' param is required");
|
||||
}
|
||||
|
||||
const parsedSelector = JSON.parse(selector);
|
||||
|
||||
if (parsedSelector.conditions && Object.keys(parsedSelector.conditions).length > 0) {
|
||||
apiDeprecationLogger.parameter(this.route, 'conditions', '9.0.0', this.response);
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findDepartmentsToAutocomplete({
|
||||
uid: this.userId,
|
||||
selector: parsedSelector,
|
||||
onlyMyDepartments: onlyMyDepartments === 'true',
|
||||
showArchived: showArchived === 'true',
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department/:_id/agents',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' },
|
||||
POST: { permissions: ['manage-livechat-departments', 'add-livechat-department-agents'], operation: 'hasAny' },
|
||||
},
|
||||
validateParams: { GET: isLivechatDepartmentDepartmentIdAgentsGETProps, POST: isLivechatDepartmentDepartmentIdAgentsPOSTProps },
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const agents = await findDepartmentAgents({
|
||||
userId: this.userId,
|
||||
departmentId: this.urlParams._id,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(agents);
|
||||
},
|
||||
async post() {
|
||||
await saveDepartmentAgents(this.urlParams._id, this.bodyParams);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/department.listByIds',
|
||||
{ authRequired: true, permissionsRequired: { GET: { permissions: ['view-livechat-departments', 'view-l-room'], operation: 'hasAny' } } },
|
||||
{
|
||||
async get() {
|
||||
const { ids } = this.queryParams;
|
||||
const { fields } = await this.parseJsonQuery();
|
||||
if (!ids) {
|
||||
return API.v1.failure("The 'ids' param is required");
|
||||
}
|
||||
if (!Array.isArray(ids)) {
|
||||
return API.v1.failure("The 'ids' param must be an array");
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findDepartmentsBetweenIds({
|
||||
ids,
|
||||
fields,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
28
apps/meteor/server/api/v1/omnichannel/index.ts
Normal file
28
apps/meteor/server/api/v1/omnichannel/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import './config';
|
||||
import './visitor';
|
||||
import './transcript';
|
||||
import './offlineMessage';
|
||||
import './pageVisited';
|
||||
import './agent';
|
||||
import './message';
|
||||
import './customField';
|
||||
import './room';
|
||||
import './transfer';
|
||||
import './contact';
|
||||
import './webhooks';
|
||||
import './integration';
|
||||
import './statistics';
|
||||
import './agentDepartments';
|
||||
import './departments';
|
||||
import './sms';
|
||||
import './users';
|
||||
import './upload';
|
||||
import './inquiries';
|
||||
import './rooms';
|
||||
import './appearance';
|
||||
import './triggers';
|
||||
import './integrations';
|
||||
import './visitors';
|
||||
import './dashboards';
|
||||
import './queue';
|
||||
import './businessHours';
|
||||
160
apps/meteor/server/api/v1/omnichannel/inquiries.ts
Normal file
160
apps/meteor/server/api/v1/omnichannel/inquiries.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { LivechatInquiryStatus } from '@rocket.chat/core-typings';
|
||||
import { LivechatInquiry, LivechatDepartment, Users, LivechatRooms } from '@rocket.chat/models';
|
||||
import {
|
||||
isGETLivechatInquiriesListParams,
|
||||
isPOSTLivechatInquiriesTakeParams,
|
||||
isGETLivechatInquiriesQueuedForUserParams,
|
||||
isGETLivechatInquiriesGetOneParams,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
isPOSTLivechatInquiriesReturnAsInquiry,
|
||||
POSTLivechatInquiriesReturnAsInquirySuccessResponse,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findInquiries, findOneInquiryByRoomId } from './lib/inquiries';
|
||||
import { returnRoomAsInquiry } from '../../../lib/omnichannel/rooms';
|
||||
import { takeInquiry } from '../../../lib/omnichannel/takeInquiry';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/inquiries.list',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: isGETLivechatInquiriesListParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { department } = this.queryParams;
|
||||
const ourQuery: { status: string; department?: string } = { status: 'queued' };
|
||||
if (department) {
|
||||
const departmentFromDB = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } });
|
||||
if (departmentFromDB) {
|
||||
ourQuery.department = departmentFromDB._id;
|
||||
}
|
||||
}
|
||||
// @ts-expect-error - attachments...
|
||||
const { cursor, totalCount } = LivechatInquiry.findPaginated(ourQuery, {
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: {
|
||||
rid: 1,
|
||||
name: 1,
|
||||
ts: 1,
|
||||
status: 1,
|
||||
department: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const [inquiries, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
inquiries,
|
||||
offset,
|
||||
count: inquiries.length,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/inquiries.take',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isPOSTLivechatInquiriesTakeParams },
|
||||
{
|
||||
async post() {
|
||||
if (this.bodyParams.userId && !(await Users.findOneById(this.bodyParams.userId, { projection: { _id: 1 } }))) {
|
||||
return API.v1.failure('The user is invalid');
|
||||
}
|
||||
return API.v1.success({
|
||||
inquiry: await takeInquiry(this.bodyParams.userId || this.userId, this.bodyParams.inquiryId, this.bodyParams.options),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/inquiries.queuedForUser',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatInquiriesQueuedForUserParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { department } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findInquiries({
|
||||
userId: this.userId,
|
||||
department,
|
||||
status: LivechatInquiryStatus.QUEUED,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/inquiries.getOne',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatInquiriesGetOneParams },
|
||||
{
|
||||
async get() {
|
||||
const { roomId } = this.queryParams;
|
||||
|
||||
return API.v1.success(
|
||||
await findOneInquiryByRoomId({
|
||||
roomId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatInquiriesEndpoints = API.v1.post(
|
||||
'livechat/inquiries.returnAsInquiry',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatInquiriesReturnAsInquirySuccessResponse,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
body: isPOSTLivechatInquiriesReturnAsInquiry,
|
||||
},
|
||||
async function action() {
|
||||
const { roomId, departmentId } = this.bodyParams;
|
||||
|
||||
try {
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
if (!room) {
|
||||
return API.v1.failure('error-room-not-found');
|
||||
}
|
||||
|
||||
const result = await returnRoomAsInquiry(room, departmentId);
|
||||
|
||||
return API.v1.success({ result });
|
||||
} catch (error) {
|
||||
if (error instanceof Meteor.Error && typeof error.error === 'string') {
|
||||
return API.v1.failure(error.error as string);
|
||||
}
|
||||
|
||||
return API.v1.failure('error-returning-inquiry');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatInquiriesEndpoints = ExtractRoutesFromAPI<typeof livechatInquiriesEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatInquiriesEndpoints {}
|
||||
}
|
||||
81
apps/meteor/server/api/v1/omnichannel/integration.ts
Normal file
81
apps/meteor/server/api/v1/omnichannel/integration.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
import { isPOSTomnichannelIntegrations } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { trim } from '../../../../lib/utils/stringUtils';
|
||||
import { notifyOnSettingChangedById } from '../../../lib/notifyListener';
|
||||
import { updateAuditedByUser } from '../../../settings/lib/auditedSettingUpdates';
|
||||
|
||||
API.v1.addRoute(
|
||||
'omnichannel/integrations',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'], validateParams: { POST: isPOSTomnichannelIntegrations } },
|
||||
{
|
||||
async post() {
|
||||
const {
|
||||
LivechatWebhookUrl,
|
||||
LivechatSecretToken,
|
||||
LivechatHttpTimeout,
|
||||
LivechatWebhookOnStart,
|
||||
LivechatWebhookOnClose,
|
||||
LivechatWebhookOnChatTaken,
|
||||
LivechatWebhookOnChatQueued,
|
||||
LivechatWebhookOnForward,
|
||||
LivechatWebhookOnOfflineMsg,
|
||||
LivechatWebhookOnVisitorMessage,
|
||||
LivechatWebhookOnAgentMessage,
|
||||
} = this.bodyParams;
|
||||
|
||||
const settingsIds = [
|
||||
typeof LivechatWebhookUrl !== 'undefined' && {
|
||||
_id: 'Livechat_webhookUrl',
|
||||
value: trim(String(LivechatWebhookUrl ?? '')),
|
||||
},
|
||||
typeof LivechatSecretToken !== 'undefined' && {
|
||||
_id: 'Livechat_secret_token',
|
||||
value: trim(String(LivechatSecretToken ?? '')),
|
||||
},
|
||||
typeof LivechatHttpTimeout !== 'undefined' && {
|
||||
_id: 'Livechat_http_timeout',
|
||||
value: Number(LivechatHttpTimeout ?? 0),
|
||||
},
|
||||
typeof LivechatWebhookOnStart !== 'undefined' && { _id: 'Livechat_webhook_on_start', value: !!LivechatWebhookOnStart },
|
||||
typeof LivechatWebhookOnClose !== 'undefined' && { _id: 'Livechat_webhook_on_close', value: !!LivechatWebhookOnClose },
|
||||
typeof LivechatWebhookOnChatTaken !== 'undefined' && { _id: 'Livechat_webhook_on_chat_taken', value: !!LivechatWebhookOnChatTaken },
|
||||
typeof LivechatWebhookOnChatQueued !== 'undefined' && {
|
||||
_id: 'Livechat_webhook_on_chat_queued',
|
||||
value: !!LivechatWebhookOnChatQueued,
|
||||
},
|
||||
typeof LivechatWebhookOnForward !== 'undefined' && { _id: 'Livechat_webhook_on_forward', value: !!LivechatWebhookOnForward },
|
||||
typeof LivechatWebhookOnOfflineMsg !== 'undefined' && {
|
||||
_id: 'Livechat_webhook_on_offline_msg',
|
||||
value: !!LivechatWebhookOnOfflineMsg,
|
||||
},
|
||||
typeof LivechatWebhookOnVisitorMessage !== 'undefined' && {
|
||||
_id: 'Livechat_webhook_on_visitor_message',
|
||||
value: !!LivechatWebhookOnVisitorMessage,
|
||||
},
|
||||
typeof LivechatWebhookOnAgentMessage !== 'undefined' && {
|
||||
_id: 'Livechat_webhook_on_agent_message',
|
||||
value: !!LivechatWebhookOnAgentMessage,
|
||||
},
|
||||
].filter(Boolean) as unknown as { _id: string; value: any }[];
|
||||
|
||||
const auditSettingOperation = updateAuditedByUser({
|
||||
_id: this.userId,
|
||||
username: this.user.username,
|
||||
ip: this.requestIp,
|
||||
useragent: this.request.headers.get('user-agent') || '',
|
||||
});
|
||||
|
||||
const promises = settingsIds.map((setting) => auditSettingOperation(Settings.updateValueById, setting._id, setting.value));
|
||||
|
||||
(await Promise.all(promises)).forEach((value, index) => {
|
||||
if (value?.modifiedCount) {
|
||||
void notifyOnSettingChangedById(settingsIds[index]._id);
|
||||
}
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
12
apps/meteor/server/api/v1/omnichannel/integrations.ts
Normal file
12
apps/meteor/server/api/v1/omnichannel/integrations.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { API } from '../..';
|
||||
import { findIntegrationSettings } from './lib/integrations';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/integrations.settings',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
|
||||
{
|
||||
async get() {
|
||||
return API.v1.success(await findIntegrationSettings());
|
||||
},
|
||||
},
|
||||
);
|
||||
14
apps/meteor/server/api/v1/omnichannel/lib/agents.ts
Normal file
14
apps/meteor/server/api/v1/omnichannel/lib/agents.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ILivechatDepartmentAgents } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartmentAgents } from '@rocket.chat/models';
|
||||
|
||||
export async function findAgentDepartments({
|
||||
enabledDepartmentsOnly,
|
||||
agentId,
|
||||
}: {
|
||||
enabledDepartmentsOnly?: boolean;
|
||||
agentId: string;
|
||||
}): Promise<{ departments: (ILivechatDepartmentAgents & { departmentName: string })[] }> {
|
||||
return {
|
||||
departments: await LivechatDepartmentAgents.findDepartmentsOfAgent(agentId, enabledDepartmentsOnly).toArray(),
|
||||
};
|
||||
}
|
||||
40
apps/meteor/server/api/v1/omnichannel/lib/appearance.ts
Normal file
40
apps/meteor/server/api/v1/omnichannel/lib/appearance.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
|
||||
export async function findAppearance(): Promise<{ appearance: ISetting[] }> {
|
||||
const query = {
|
||||
_id: {
|
||||
$in: [
|
||||
'Livechat_title',
|
||||
'Livechat_title_color',
|
||||
'Livechat_enable_message_character_limit',
|
||||
'Livechat_message_character_limit',
|
||||
'Livechat_show_agent_info',
|
||||
'Livechat_show_agent_email',
|
||||
'Livechat_display_offline_form',
|
||||
'Livechat_offline_form_unavailable',
|
||||
'Livechat_offline_message',
|
||||
'Livechat_offline_success_message',
|
||||
'Livechat_offline_title',
|
||||
'Livechat_offline_title_color',
|
||||
'Livechat_offline_email',
|
||||
'Livechat_conversation_finished_message',
|
||||
'Livechat_registration_form',
|
||||
'Livechat_name_field_registration_form',
|
||||
'Livechat_email_field_registration_form',
|
||||
'Livechat_registration_form_message',
|
||||
'Livechat_conversation_finished_text',
|
||||
'Livechat_hide_watermark',
|
||||
'Livechat_background',
|
||||
'Livechat_widget_position',
|
||||
'Livechat_hide_system_messages',
|
||||
'Omnichannel_allow_visitors_to_close_conversation',
|
||||
'Livechat_hide_expand_chat',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
appearance: await Settings.find(query).toArray(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
|
||||
|
||||
import { businessHourManager } from '../../../../lib/omnichannel/business-hour';
|
||||
|
||||
export async function findLivechatBusinessHour(id?: string, type?: string): Promise<Record<string, ILivechatBusinessHour>> {
|
||||
return {
|
||||
businessHour: (await businessHourManager.getBusinessHour(id, type)) as ILivechatBusinessHour,
|
||||
};
|
||||
}
|
||||
61
apps/meteor/server/api/v1/omnichannel/lib/customFields.ts
Normal file
61
apps/meteor/server/api/v1/omnichannel/lib/customFields.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { ILivechatCustomField } from '@rocket.chat/core-typings';
|
||||
import { LivechatCustomField, LivechatVisitors, LivechatRooms } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { UpdateResult, Document } from 'mongodb';
|
||||
|
||||
export async function findLivechatCustomFields({
|
||||
text,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
text?: string;
|
||||
pagination: { offset: number; count: number; sort: Record<string, 1 | -1> };
|
||||
}): Promise<PaginatedResult<{ customFields: Array<ILivechatCustomField> }>> {
|
||||
const query = {
|
||||
...(text && {
|
||||
$or: [{ label: new RegExp(escapeRegExp(text), 'i') }, { _id: new RegExp(escapeRegExp(text), 'i') }],
|
||||
}),
|
||||
};
|
||||
|
||||
const { cursor, totalCount } = LivechatCustomField.findPaginated(query, {
|
||||
sort: sort || { label: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [customFields, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
customFields,
|
||||
count: customFields.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findCustomFieldById({
|
||||
customFieldId,
|
||||
}: {
|
||||
customFieldId: string;
|
||||
}): Promise<{ customField: ILivechatCustomField | null }> {
|
||||
return {
|
||||
customField: await LivechatCustomField.findOneById(customFieldId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setCustomField(
|
||||
token: string,
|
||||
key: string,
|
||||
value: string,
|
||||
overwrite = true,
|
||||
): Promise<boolean | UpdateResult | Document> {
|
||||
const customField = await LivechatCustomField.findOneById(key);
|
||||
if (customField) {
|
||||
if (customField.scope === 'room') {
|
||||
return LivechatRooms.updateDataByToken(token, key, value, overwrite);
|
||||
}
|
||||
return LivechatVisitors.updateLivechatDataByToken(token, key, value, overwrite);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
197
apps/meteor/server/api/v1/omnichannel/lib/departments.ts
Normal file
197
apps/meteor/server/api/v1/omnichannel/lib/departments.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models';
|
||||
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { Document, Filter, FilterOperators, FindOptions } from 'mongodb';
|
||||
|
||||
import { hasPermissionAsync } from '../../../../lib/authorization/hasPermission';
|
||||
|
||||
type Pagination<T extends Document> = { pagination: { offset: number; count: number; sort: FindOptions<T>['sort'] } };
|
||||
type FindDepartmentParams = {
|
||||
userId: string;
|
||||
onlyMyDepartments?: boolean;
|
||||
text?: string;
|
||||
enabled?: boolean;
|
||||
excludeDepartmentId?: string;
|
||||
showArchived?: boolean;
|
||||
} & Pagination<ILivechatDepartment>;
|
||||
type FindDepartmentByIdParams = {
|
||||
userId: string;
|
||||
departmentId: string;
|
||||
includeAgents?: boolean;
|
||||
onlyMyDepartments?: boolean;
|
||||
};
|
||||
type FindDepartmentToAutocompleteParams = {
|
||||
uid: string;
|
||||
selector: {
|
||||
exceptions: string[];
|
||||
conditions: Filter<ILivechatDepartment>;
|
||||
term: string;
|
||||
};
|
||||
onlyMyDepartments?: boolean;
|
||||
showArchived?: boolean;
|
||||
};
|
||||
type FindDepartmentAgentsParams = {
|
||||
userId: string;
|
||||
departmentId: string;
|
||||
} & Pagination<ILivechatDepartmentAgents>;
|
||||
|
||||
export async function findDepartments({
|
||||
userId,
|
||||
onlyMyDepartments = false,
|
||||
text,
|
||||
enabled,
|
||||
excludeDepartmentId,
|
||||
showArchived = false,
|
||||
pagination: { offset, count, sort },
|
||||
}: FindDepartmentParams): Promise<PaginatedResult<{ departments: ILivechatDepartment[] }>> {
|
||||
let query: FilterOperators<ILivechatDepartment> = {
|
||||
$or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }],
|
||||
...(!showArchived && { archived: { $ne: !showArchived } }),
|
||||
...(enabled && { enabled: Boolean(enabled) }),
|
||||
...(text && { name: new RegExp(escapeRegExp(text), 'i') }),
|
||||
...(excludeDepartmentId && { _id: { $ne: excludeDepartmentId } }),
|
||||
};
|
||||
|
||||
if (onlyMyDepartments) {
|
||||
query = await applyDepartmentRestrictions(query, userId);
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = LivechatDepartment.findPaginated(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [departments, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findArchivedDepartments({
|
||||
userId,
|
||||
onlyMyDepartments = false,
|
||||
text,
|
||||
excludeDepartmentId,
|
||||
pagination: { offset, count, sort },
|
||||
}: FindDepartmentParams): Promise<PaginatedResult<{ departments: ILivechatDepartment[] }>> {
|
||||
let query: FilterOperators<ILivechatDepartment> = {
|
||||
$or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }],
|
||||
archived: { $eq: true },
|
||||
...(text && { name: new RegExp(escapeRegExp(text), 'i') }),
|
||||
...(excludeDepartmentId && { _id: { $ne: excludeDepartmentId } }),
|
||||
};
|
||||
|
||||
if (onlyMyDepartments) {
|
||||
query = await applyDepartmentRestrictions(query, userId);
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = LivechatDepartment.findPaginated(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [departments, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
departments,
|
||||
count: departments.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findDepartmentById({
|
||||
userId,
|
||||
departmentId,
|
||||
includeAgents = true,
|
||||
onlyMyDepartments = false,
|
||||
}: FindDepartmentByIdParams): Promise<{
|
||||
department: ILivechatDepartment | null;
|
||||
agents?: ILivechatDepartmentAgents[];
|
||||
}> {
|
||||
const canViewLivechatDepartments = includeAgents && (await hasPermissionAsync(userId, 'view-livechat-departments'));
|
||||
|
||||
let query: FilterOperators<ILivechatDepartment> = { _id: departmentId };
|
||||
|
||||
if (onlyMyDepartments) {
|
||||
query = await applyDepartmentRestrictions(query, userId);
|
||||
}
|
||||
|
||||
const result = {
|
||||
department: await LivechatDepartment.findOne(query),
|
||||
...(includeAgents &&
|
||||
canViewLivechatDepartments && {
|
||||
agents: await LivechatDepartmentAgents.findByDepartmentId(departmentId).toArray(),
|
||||
}),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function findDepartmentsToAutocomplete({
|
||||
uid,
|
||||
selector,
|
||||
onlyMyDepartments = false,
|
||||
showArchived = false,
|
||||
}: FindDepartmentToAutocompleteParams): Promise<{ items: Pick<ILivechatDepartment, '_id' | 'name'>[] }> {
|
||||
const { exceptions = [] } = selector;
|
||||
let { conditions = {} } = selector;
|
||||
|
||||
if (onlyMyDepartments) {
|
||||
conditions = await applyDepartmentRestrictions(conditions, uid);
|
||||
}
|
||||
|
||||
const conditionsWithArchived = { archived: { $ne: !showArchived }, ...conditions };
|
||||
|
||||
const items = await LivechatDepartment.findByNameRegexWithExceptionsAndConditions(selector.term, exceptions, conditionsWithArchived, {
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
},
|
||||
sort: {
|
||||
name: 1,
|
||||
},
|
||||
}).toArray();
|
||||
return {
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findDepartmentAgents({
|
||||
departmentId,
|
||||
pagination: { offset, count, sort },
|
||||
}: FindDepartmentAgentsParams): Promise<PaginatedResult<{ agents: ILivechatDepartmentAgents[] }>> {
|
||||
const { cursor, totalCount } = LivechatDepartmentAgents.findAgentsByDepartmentId<ILivechatDepartmentAgents>(departmentId, {
|
||||
sort: sort || { username: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [agents, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
agents,
|
||||
count: agents.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findDepartmentsBetweenIds({
|
||||
ids,
|
||||
fields,
|
||||
}: {
|
||||
ids: string[];
|
||||
fields: Record<string, unknown>;
|
||||
}): Promise<{ departments: ILivechatDepartment[] }> {
|
||||
const departments = await LivechatDepartment.findInIds(ids, { projection: fields }).toArray();
|
||||
return { departments };
|
||||
}
|
||||
90
apps/meteor/server/api/v1/omnichannel/lib/inquiries.ts
Normal file
90
apps/meteor/server/api/v1/omnichannel/lib/inquiries.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { LivechatInquiryStatus } from '@rocket.chat/core-typings';
|
||||
import type { ILivechatInquiryRecord, IRoom, IUser } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartmentAgents, LivechatDepartment, LivechatInquiry } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
import { getOmniChatSortQuery } from '../../../../../app/livechat/lib/inquiries';
|
||||
import { getInquirySortMechanismSetting } from '../../../../lib/omnichannel/settings';
|
||||
|
||||
const agentDepartments = async (userId: IUser['_id']): Promise<string[]> => {
|
||||
const agentDepartments = (await LivechatDepartmentAgents.findByAgentId(userId, { projection: { departmentId: 1 } }).toArray()).map(
|
||||
({ departmentId }) => departmentId,
|
||||
);
|
||||
return (await LivechatDepartment.findEnabledInIds(agentDepartments, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id);
|
||||
};
|
||||
|
||||
const applyDepartmentRestrictions = async (
|
||||
userId: IUser['_id'],
|
||||
filterDepartment?: string,
|
||||
): Promise<{ $in: string[] } | { $exists: false } | string> => {
|
||||
const allowedDepartments = await agentDepartments(userId);
|
||||
if (allowedDepartments && Array.isArray(allowedDepartments) && allowedDepartments.length > 0) {
|
||||
if (!filterDepartment) {
|
||||
return { $in: allowedDepartments };
|
||||
}
|
||||
|
||||
if (!allowedDepartments.includes(filterDepartment)) {
|
||||
throw new Error('error-not-authorized');
|
||||
}
|
||||
return filterDepartment;
|
||||
}
|
||||
|
||||
return { $exists: false };
|
||||
};
|
||||
|
||||
export async function findInquiries({
|
||||
userId,
|
||||
department: filterDepartment,
|
||||
status,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
userId: IUser['_id'];
|
||||
department?: string;
|
||||
status?: LivechatInquiryStatus;
|
||||
pagination: { offset: number; count: number; sort: Record<string, number> };
|
||||
}): Promise<PaginatedResult<{ inquiries: Array<ILivechatInquiryRecord> }>> {
|
||||
const department = await applyDepartmentRestrictions(userId, filterDepartment);
|
||||
const defaultSort = getOmniChatSortQuery(getInquirySortMechanismSetting());
|
||||
const options = {
|
||||
limit: count,
|
||||
skip: offset,
|
||||
sort: { ...sort, ...defaultSort },
|
||||
};
|
||||
|
||||
const filter: Filter<ILivechatInquiryRecord> = {
|
||||
// V in Enum only works for numeric enums
|
||||
...(status && Object.values(LivechatInquiryStatus).includes(status) && { status }),
|
||||
$or: [
|
||||
// Cases where this user is the default agent
|
||||
{
|
||||
'defaultAgent': { $exists: true },
|
||||
'defaultAgent.agentId': userId,
|
||||
},
|
||||
// Cases with no default agent assigned yet, AND either:
|
||||
// - belongs to one of user's departments, or
|
||||
// - has no department (public queue)
|
||||
{
|
||||
defaultAgent: { $exists: false },
|
||||
$or: [...(department ? [{ department }] : []), { department: { $exists: false } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { cursor, totalCount } = LivechatInquiry.findPaginated(filter, options);
|
||||
|
||||
const [inquiries, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
inquiries,
|
||||
count: inquiries.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOneInquiryByRoomId({ roomId }: { roomId: IRoom['_id'] }): Promise<{ inquiry: ILivechatInquiryRecord | null }> {
|
||||
return {
|
||||
inquiry: await LivechatInquiry.findOneByRoomId(roomId, {}),
|
||||
};
|
||||
}
|
||||
20
apps/meteor/server/api/v1/omnichannel/lib/integrations.ts
Normal file
20
apps/meteor/server/api/v1/omnichannel/lib/integrations.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
|
||||
export async function findIntegrationSettings(): Promise<{ settings: ISetting[] }> {
|
||||
const settings = await Settings.findByIds([
|
||||
'Livechat_webhookUrl',
|
||||
'Livechat_secret_token',
|
||||
'Livechat_http_timeout',
|
||||
'Livechat_webhook_on_start',
|
||||
'Livechat_webhook_on_close',
|
||||
'Livechat_webhook_on_chat_taken',
|
||||
'Livechat_webhook_on_chat_queued',
|
||||
'Livechat_webhook_on_forward',
|
||||
'Livechat_webhook_on_offline_msg',
|
||||
'Livechat_webhook_on_visitor_message',
|
||||
'Livechat_webhook_on_agent_message',
|
||||
]).toArray();
|
||||
|
||||
return { settings };
|
||||
}
|
||||
198
apps/meteor/server/api/v1/omnichannel/lib/livechat.ts
Normal file
198
apps/meteor/server/api/v1/omnichannel/lib/livechat.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { ILivechatAgent, ILivechatDepartment, ILivechatTrigger, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { EmojiCustom, LivechatTrigger, LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
|
||||
import { makeFunction } from '@rocket.chat/patch-injection';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { callbacks } from '../../../../lib/callbacks';
|
||||
import { normalizeAgent } from '../../../../lib/omnichannel/Helper';
|
||||
import { getInitSettings } from '../../../../lib/omnichannel/settings';
|
||||
|
||||
async function findTriggers(): Promise<Pick<ILivechatTrigger, '_id' | 'actions' | 'conditions' | 'runOnce'>[]> {
|
||||
const triggers = await LivechatTrigger.findEnabled().toArray();
|
||||
const hasLicense = License.hasModule('livechat-enterprise');
|
||||
const premiumActions = ['use-external-service'];
|
||||
|
||||
return triggers
|
||||
.filter(({ actions }) => hasLicense || actions.some((c) => !premiumActions.includes(c.name)))
|
||||
.map(({ _id, actions, conditions, runOnce }) => ({
|
||||
_id,
|
||||
actions,
|
||||
conditions,
|
||||
runOnce,
|
||||
}));
|
||||
}
|
||||
|
||||
export type CheckUnitsFromUser = {
|
||||
userId?: string;
|
||||
businessUnit?: string;
|
||||
};
|
||||
|
||||
export const checkUnitsFromUser = makeFunction(async (_params: CheckUnitsFromUser): Promise<void> => undefined);
|
||||
|
||||
async function findDepartments(
|
||||
businessUnit?: string,
|
||||
userId?: string,
|
||||
): Promise<Pick<ILivechatDepartment, '_id' | 'name' | 'showOnRegistration' | 'showOnOfflineForm' | 'departmentsAllowedToForward'>[]> {
|
||||
// TODO: check this function usage
|
||||
await checkUnitsFromUser({ userId, businessUnit });
|
||||
|
||||
return LivechatDepartment.findEnabledWithAgentsAndBusinessUnit<
|
||||
Pick<ILivechatDepartment, '_id' | 'name' | 'showOnRegistration' | 'showOnOfflineForm' | 'departmentsAllowedToForward'>
|
||||
>(businessUnit, {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
showOnRegistration: 1,
|
||||
showOnOfflineForm: 1,
|
||||
departmentsAllowedToForward: 1,
|
||||
}).toArray();
|
||||
}
|
||||
|
||||
export function findGuest(token: string): Promise<ILivechatVisitor | null> {
|
||||
return LivechatVisitors.getVisitorByToken(token);
|
||||
}
|
||||
|
||||
export function findGuestWithoutActivity(
|
||||
token: string,
|
||||
): Promise<Pick<ILivechatVisitor, '_id' | 'name' | 'username' | 'token' | 'visitorEmails' | 'department'> | null> {
|
||||
return LivechatVisitors.getVisitorByToken(token, { projection: { name: 1, username: 1, token: 1, visitorEmails: 1, department: 1 } });
|
||||
}
|
||||
|
||||
export async function findRoom(token: string, rid?: string): Promise<IOmnichannelRoom | null> {
|
||||
const fields = {
|
||||
t: 1,
|
||||
departmentId: 1,
|
||||
servedBy: 1,
|
||||
open: 1,
|
||||
v: 1,
|
||||
ts: 1,
|
||||
};
|
||||
|
||||
if (!rid) {
|
||||
return LivechatRooms.findOneByVisitorToken(token, fields);
|
||||
}
|
||||
|
||||
return LivechatRooms.findOneByIdAndVisitorToken(rid, token, fields);
|
||||
}
|
||||
|
||||
export async function findOpenRoom(token: string, departmentId?: string, callerId?: string): Promise<IOmnichannelRoom | undefined> {
|
||||
const options = {
|
||||
projection: {
|
||||
departmentId: 1,
|
||||
servedBy: 1,
|
||||
open: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: callerId });
|
||||
const rooms = departmentId
|
||||
? await LivechatRooms.findOpenByVisitorTokenAndDepartmentId(token, departmentId, options, extraQuery).toArray()
|
||||
: await LivechatRooms.findOpenByVisitorToken(token, options, extraQuery).toArray();
|
||||
if (rooms && rooms.length > 0) {
|
||||
return rooms[0];
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAgent(agentId?: string): Promise<void | { hiddenInfo: boolean } | ILivechatAgent> {
|
||||
return normalizeAgent(agentId);
|
||||
}
|
||||
|
||||
export function normalizeHttpHeaderData(headers: Headers = new Headers()): {
|
||||
httpHeaders: Record<string, string | string[] | undefined>;
|
||||
} {
|
||||
const httpHeaders = Object.fromEntries(headers.entries());
|
||||
return { httpHeaders };
|
||||
}
|
||||
|
||||
export async function settings({ businessUnit = '', userId }: { businessUnit?: string; userId?: string } = {}): Promise<
|
||||
Record<string, string | number | any>
|
||||
> {
|
||||
const [initSettings, triggers, departments, emojis] = await Promise.all([
|
||||
getInitSettings(),
|
||||
findTriggers(),
|
||||
findDepartments(businessUnit, userId),
|
||||
EmojiCustom.find().toArray(),
|
||||
]);
|
||||
const sound = `${Meteor.absoluteUrl()}sounds/chime.mp3`;
|
||||
return {
|
||||
enabled: initSettings.Livechat_enabled,
|
||||
settings: {
|
||||
registrationForm: initSettings.Livechat_registration_form,
|
||||
allowSwitchingDepartments: initSettings.Livechat_allow_switching_departments,
|
||||
nameFieldRegistrationForm: initSettings.Livechat_name_field_registration_form,
|
||||
emailFieldRegistrationForm: initSettings.Livechat_email_field_registration_form,
|
||||
displayOfflineForm: initSettings.Livechat_display_offline_form,
|
||||
videoCall: initSettings.Omnichannel_call_provider === 'default-provider',
|
||||
fileUpload: initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled,
|
||||
language: initSettings.Language,
|
||||
transcript: initSettings.Livechat_enable_transcript,
|
||||
historyMonitorType: initSettings.Livechat_history_monitor_type,
|
||||
forceAcceptDataProcessingConsent: initSettings.Livechat_force_accept_data_processing_consent,
|
||||
showConnecting: initSettings.Livechat_Show_Connecting,
|
||||
agentHiddenInfo: initSettings.Livechat_show_agent_info === false,
|
||||
clearLocalStorageWhenChatEnded: initSettings.Livechat_clear_local_storage_when_chat_ended,
|
||||
limitTextLength:
|
||||
initSettings.Livechat_enable_message_character_limit &&
|
||||
(initSettings.Livechat_message_character_limit || initSettings.Message_MaxAllowedSize),
|
||||
hiddenSystemMessages: initSettings.Livechat_hide_system_messages,
|
||||
livechatLogo: initSettings.Assets_livechat_widget_logo,
|
||||
hideWatermark: initSettings.Livechat_hide_watermark || false,
|
||||
visitorsCanCloseChat: initSettings.Omnichannel_allow_visitors_to_close_conversation,
|
||||
},
|
||||
theme: {
|
||||
title: initSettings.Livechat_title,
|
||||
color: initSettings.Livechat_title_color,
|
||||
offlineTitle: initSettings.Livechat_offline_title,
|
||||
offlineColor: initSettings.Livechat_offline_title_color,
|
||||
position: initSettings.Livechat_widget_position || 'right',
|
||||
background: initSettings.Livechat_background,
|
||||
hideExpandChat: initSettings.Livechat_hide_expand_chat,
|
||||
actionLinks: {
|
||||
jitsi: [
|
||||
{ icon: 'icon-videocam', i18nLabel: 'Accept' },
|
||||
{ icon: 'icon-cancel', i18nLabel: 'Decline' },
|
||||
],
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
offlineMessage: initSettings.Livechat_offline_message,
|
||||
offlineSuccessMessage: initSettings.Livechat_offline_success_message,
|
||||
offlineUnavailableMessage: initSettings.Livechat_offline_form_unavailable,
|
||||
conversationFinishedMessage: initSettings.Livechat_conversation_finished_message,
|
||||
conversationFinishedText: initSettings.Livechat_conversation_finished_text,
|
||||
transcriptMessage: initSettings.Livechat_transcript_message,
|
||||
registrationFormMessage: initSettings.Livechat_registration_form_message,
|
||||
dataProcessingConsentText: initSettings.Livechat_data_processing_consent_text,
|
||||
},
|
||||
survey: {
|
||||
items: ['satisfaction', 'agentKnowledge', 'agentResposiveness', 'agentFriendliness'],
|
||||
values: ['1', '2', '3', '4', '5'],
|
||||
},
|
||||
triggers,
|
||||
departments,
|
||||
resources: {
|
||||
sound,
|
||||
emojis,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const getExtraConfigInfo = makeFunction(
|
||||
async (options: {
|
||||
room?: IOmnichannelRoom;
|
||||
}): Promise<{
|
||||
queueInfo?: unknown;
|
||||
customFields?: {
|
||||
options?: string[] | undefined;
|
||||
_id: string;
|
||||
label: string;
|
||||
regexp: string | undefined;
|
||||
required: boolean;
|
||||
type: string | undefined;
|
||||
defaultValue: string | null;
|
||||
}[];
|
||||
room?: IOmnichannelRoom;
|
||||
}> => options,
|
||||
);
|
||||
|
||||
export const onCheckRoomParams = makeFunction((params: any) => params);
|
||||
46
apps/meteor/server/api/v1/omnichannel/lib/queue.ts
Normal file
46
apps/meteor/server/api/v1/omnichannel/lib/queue.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { LivechatRooms } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
|
||||
export async function findQueueMetrics({
|
||||
agentId,
|
||||
includeOfflineAgents,
|
||||
departmentId,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
agentId?: string;
|
||||
includeOfflineAgents?: boolean;
|
||||
departmentId?: string;
|
||||
pagination: { offset: number; count: number; sort: Record<string, number> };
|
||||
}): Promise<
|
||||
PaginatedResult<{
|
||||
queue: Array<{
|
||||
_id: string;
|
||||
user: { _id: string; userId: string; username: string; status: string };
|
||||
department: { _id: string; name: string };
|
||||
chats: number;
|
||||
}>;
|
||||
}>
|
||||
> {
|
||||
const result = await LivechatRooms.getQueueMetrics({
|
||||
departmentId,
|
||||
agentId,
|
||||
includeOfflineAgents,
|
||||
options: {
|
||||
sort: sort || { chats: -1 },
|
||||
offset,
|
||||
count,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
sortedResults: queue,
|
||||
totalCount: [{ total } = { total: 0 }],
|
||||
} = result[0];
|
||||
|
||||
return {
|
||||
queue,
|
||||
count: queue.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
92
apps/meteor/server/api/v1/omnichannel/lib/rooms.ts
Normal file
92
apps/meteor/server/api/v1/omnichannel/lib/rooms.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ILivechatDepartment, IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { callbacks } from '../../../../lib/callbacks';
|
||||
|
||||
export async function findRooms({
|
||||
agents,
|
||||
roomName,
|
||||
departmentId,
|
||||
open,
|
||||
createdAt,
|
||||
closedAt,
|
||||
tags,
|
||||
customFields,
|
||||
onhold,
|
||||
queued,
|
||||
units,
|
||||
query,
|
||||
options: { offset, count, fields, sort },
|
||||
callerId,
|
||||
}: {
|
||||
agents?: Array<string>;
|
||||
roomName?: string;
|
||||
departmentId?: string;
|
||||
open?: boolean;
|
||||
createdAt?: {
|
||||
start?: string | undefined;
|
||||
end?: string | undefined;
|
||||
};
|
||||
closedAt?: {
|
||||
start?: string | undefined;
|
||||
end?: string | undefined;
|
||||
};
|
||||
tags?: Array<string>;
|
||||
customFields?: Record<string, string>;
|
||||
onhold?: string | boolean;
|
||||
queued?: string | boolean;
|
||||
units?: Array<string>;
|
||||
query?: Record<string, any>;
|
||||
options: { offset: number; count: number; fields: Record<string, number>; sort: Record<string, number> };
|
||||
callerId: string;
|
||||
}): Promise<PaginatedResult<{ rooms: Array<IOmnichannelRoom> }>> {
|
||||
const extraQueryBase = await callbacks.run('livechat.applyRoomRestrictions', {}, { unitsFilter: units, userId: callerId });
|
||||
const extraQuery = { ...extraQueryBase, ...(query || {}) };
|
||||
const { cursor, totalCount } = LivechatRooms.findRoomsWithCriteria({
|
||||
agents,
|
||||
roomName,
|
||||
departmentId,
|
||||
open,
|
||||
createdAt,
|
||||
closedAt,
|
||||
tags,
|
||||
customFields,
|
||||
onhold: ['t', 'true', '1'].includes(`${onhold}`),
|
||||
queued: ['t', 'true', '1'].includes(`${queued}`),
|
||||
options: {
|
||||
sort: sort || { ts: -1 },
|
||||
offset,
|
||||
count,
|
||||
fields,
|
||||
},
|
||||
extraQuery,
|
||||
});
|
||||
|
||||
const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
const isRoomWithDepartmentId = (depId: string | undefined): depId is string => !!depId;
|
||||
|
||||
const departmentsIds = [...new Set(rooms.map((room) => room.departmentId).filter(isRoomWithDepartmentId))];
|
||||
if (departmentsIds.length) {
|
||||
const departments = await LivechatDepartment.findInIds(departmentsIds, {
|
||||
projection: { name: 1 },
|
||||
}).toArray();
|
||||
|
||||
rooms.forEach((room: IOmnichannelRoom & { department?: Pick<ILivechatDepartment, '_id' | 'name'> }) => {
|
||||
if (!room.departmentId) {
|
||||
return;
|
||||
}
|
||||
const department = departments.find((dept) => dept._id === room.departmentId);
|
||||
if (department) {
|
||||
room.department = department;
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
rooms,
|
||||
count: rooms.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
34
apps/meteor/server/api/v1/omnichannel/lib/transfer.ts
Normal file
34
apps/meteor/server/api/v1/omnichannel/lib/transfer.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { IOmnichannelSystemMessage } from '@rocket.chat/core-typings';
|
||||
import { Messages } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
|
||||
const normalizeTransferHistory = ({ transferData }: IOmnichannelSystemMessage): IOmnichannelSystemMessage['transferData'] => transferData;
|
||||
|
||||
const removeNulls = <S>(value: S | undefined): value is S => value != null;
|
||||
|
||||
export async function findLivechatTransferHistory({
|
||||
rid,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
rid: string;
|
||||
pagination: { offset: number; count: number; sort: Record<string, 1 | -1> };
|
||||
}): Promise<PaginatedResult<{ history: IOmnichannelSystemMessage['transferData'][] }>> {
|
||||
const { cursor, totalCount } = Messages.findPaginated(
|
||||
{ rid, t: 'livechat_transfer_history' },
|
||||
{
|
||||
projection: { transferData: 1 },
|
||||
sort: sort || { ts: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
},
|
||||
);
|
||||
|
||||
const [history, total] = await Promise.all([cursor.map(normalizeTransferHistory).toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
history: history.filter(removeNulls),
|
||||
count: history.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
35
apps/meteor/server/api/v1/omnichannel/lib/triggers.ts
Normal file
35
apps/meteor/server/api/v1/omnichannel/lib/triggers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ILivechatTrigger } from '@rocket.chat/core-typings';
|
||||
import { LivechatTrigger } from '@rocket.chat/models';
|
||||
import type { PaginatedResult } from '@rocket.chat/rest-typings';
|
||||
|
||||
export async function findTriggers({
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
pagination: { offset: number; count: number; sort: Record<string, 1 | -1> };
|
||||
}): Promise<PaginatedResult<{ triggers: Array<ILivechatTrigger> }>> {
|
||||
const { cursor, totalCount } = LivechatTrigger.findPaginated(
|
||||
{},
|
||||
{
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
},
|
||||
);
|
||||
|
||||
const [triggers, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
triggers,
|
||||
count: triggers.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findTriggerById({ triggerId }: { triggerId: string }): Promise<ILivechatTrigger | null> {
|
||||
return LivechatTrigger.findOneById(triggerId);
|
||||
}
|
||||
|
||||
export async function deleteTrigger({ triggerId }: { triggerId: string }): Promise<void> {
|
||||
await LivechatTrigger.removeById(triggerId);
|
||||
}
|
||||
118
apps/meteor/server/api/v1/omnichannel/lib/users.ts
Normal file
118
apps/meteor/server/api/v1/omnichannel/lib/users.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { ILivechatAgent, IRole } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { FilterOperators } from 'mongodb';
|
||||
|
||||
/**
|
||||
* @param {IRole['_id']} role the role id
|
||||
* @param {string} text
|
||||
* @param {any} pagination
|
||||
*/
|
||||
async function findUsers({
|
||||
role,
|
||||
text,
|
||||
onlyAvailable = false,
|
||||
excludeId,
|
||||
showIdleAgents = true,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
role: IRole['_id'];
|
||||
text?: string;
|
||||
onlyAvailable?: boolean;
|
||||
excludeId?: string;
|
||||
showIdleAgents?: boolean;
|
||||
pagination: { offset: number; count: number; sort: any };
|
||||
}): Promise<{ users: ILivechatAgent[]; count: number; offset: number; total: number }> {
|
||||
const query: FilterOperators<ILivechatAgent> = {};
|
||||
const orConditions: FilterOperators<ILivechatAgent>['$or'] = [];
|
||||
if (text) {
|
||||
const filterReg = new RegExp(escapeRegExp(text), 'i');
|
||||
orConditions.push({ $or: [{ username: filterReg }, { name: filterReg }, { 'emails.address': filterReg }] });
|
||||
}
|
||||
|
||||
if (onlyAvailable) {
|
||||
query.statusLivechat = 'available';
|
||||
}
|
||||
|
||||
if (excludeId) {
|
||||
query._id = { $ne: excludeId };
|
||||
}
|
||||
|
||||
if (!showIdleAgents) {
|
||||
orConditions.push({ $or: [{ status: { $exists: true, $ne: 'offline' }, roles: { $ne: 'bot' } }, { roles: 'bot' }] });
|
||||
}
|
||||
|
||||
if (orConditions.length) {
|
||||
query.$and = orConditions;
|
||||
}
|
||||
|
||||
const [
|
||||
{
|
||||
sortedResults,
|
||||
totalCount: [{ total } = { total: 0 }],
|
||||
},
|
||||
] = await Users.findAgentsWithDepartments(role, query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: {
|
||||
username: 1,
|
||||
name: 1,
|
||||
status: 1,
|
||||
statusLivechat: 1,
|
||||
emails: 1,
|
||||
livechat: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
users: sortedResults,
|
||||
count: sortedResults.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
export async function findAgents({
|
||||
text,
|
||||
onlyAvailable = false,
|
||||
excludeId,
|
||||
showIdleAgents = true,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
text?: string;
|
||||
onlyAvailable: boolean;
|
||||
excludeId?: string;
|
||||
showIdleAgents?: boolean;
|
||||
pagination: { offset: number; count: number; sort: any };
|
||||
}): Promise<ReturnType<typeof findUsers>> {
|
||||
return findUsers({
|
||||
role: 'livechat-agent',
|
||||
text,
|
||||
onlyAvailable,
|
||||
excludeId,
|
||||
showIdleAgents,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findManagers({
|
||||
text,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
text?: string;
|
||||
pagination: { offset: number; count: number; sort: any };
|
||||
}): Promise<ReturnType<typeof findUsers>> {
|
||||
return findUsers({
|
||||
role: 'livechat-manager',
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
216
apps/meteor/server/api/v1/omnichannel/lib/visitors.ts
Normal file
216
apps/meteor/server/api/v1/omnichannel/lib/visitors.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type { ILivechatVisitor, IMessage, IOmnichannelRoom, IRoom, IUser, IVisitor } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors, Messages, LivechatRooms, LivechatCustomField } from '@rocket.chat/models';
|
||||
import type { FindOptions } from 'mongodb';
|
||||
|
||||
import { canAccessRoomAsync } from '../../../../lib/authorization/canAccessRoom';
|
||||
import { callbacks } from '../../../../lib/callbacks';
|
||||
|
||||
export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id'] }) {
|
||||
const visitor = await LivechatVisitors.findOneEnabledById(visitorId, { projection: { token: 0 } });
|
||||
if (!visitor) {
|
||||
throw new Error('visitor-not-found');
|
||||
}
|
||||
|
||||
return {
|
||||
visitor,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findVisitedPages({
|
||||
roomId,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
roomId: IRoom['_id'];
|
||||
pagination: { offset: number; count: number; sort: FindOptions<IMessage>['sort'] };
|
||||
}) {
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
const { cursor, totalCount } = Messages.findPaginatedByRoomIdAndType(room._id, 'livechat_navigation_history', {
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [pages, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
pages,
|
||||
count: pages.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findChatHistory({
|
||||
userId,
|
||||
roomId,
|
||||
visitorId,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
userId: IUser['_id'];
|
||||
roomId: IRoom['_id'];
|
||||
visitorId: IVisitor['_id'];
|
||||
pagination: { offset: number; count: number; sort: FindOptions<IOmnichannelRoom>['sort'] };
|
||||
}) {
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
if (!(await canAccessRoomAsync(room, { _id: userId }))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId });
|
||||
const { cursor, totalCount } = LivechatRooms.findPaginatedByVisitorId(
|
||||
visitorId,
|
||||
{
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
},
|
||||
extraQuery,
|
||||
);
|
||||
|
||||
const [history, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
history,
|
||||
count: history.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchChats({
|
||||
userId,
|
||||
roomId,
|
||||
visitorId,
|
||||
searchText,
|
||||
closedChatsOnly,
|
||||
servedChatsOnly: served,
|
||||
source,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
userId: IUser['_id'];
|
||||
roomId: IRoom['_id'];
|
||||
visitorId: IVisitor['_id'];
|
||||
searchText?: string;
|
||||
closedChatsOnly?: string;
|
||||
servedChatsOnly?: string;
|
||||
source?: string;
|
||||
pagination: { offset: number; count: number; sort: FindOptions<IOmnichannelRoom>['sort'] };
|
||||
}) {
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, { _id: userId }))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
const options = {
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
};
|
||||
|
||||
const [total] = await LivechatRooms.findRoomsByVisitorIdAndMessageWithCriteria({
|
||||
visitorId,
|
||||
open: closedChatsOnly !== 'true',
|
||||
served: served === 'true',
|
||||
searchText,
|
||||
onlyCount: true,
|
||||
source,
|
||||
}).toArray();
|
||||
const cursor = await LivechatRooms.findRoomsByVisitorIdAndMessageWithCriteria({
|
||||
visitorId,
|
||||
open: closedChatsOnly !== 'true',
|
||||
served: served === 'true',
|
||||
searchText,
|
||||
options,
|
||||
source,
|
||||
});
|
||||
|
||||
const history = await cursor.toArray();
|
||||
|
||||
return {
|
||||
history,
|
||||
count: history.length,
|
||||
offset,
|
||||
total: total?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findVisitorsToAutocomplete({
|
||||
selector,
|
||||
}: {
|
||||
selector: {
|
||||
exceptions?: ILivechatVisitor['_id'][];
|
||||
conditions?: Record<string, unknown>;
|
||||
term: string;
|
||||
};
|
||||
}) {
|
||||
const { exceptions = [], conditions = {} } = selector;
|
||||
|
||||
const options: FindOptions<ILivechatVisitor> = {
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
username: 1,
|
||||
},
|
||||
limit: 10,
|
||||
sort: {
|
||||
name: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const items = await LivechatVisitors.findByNameRegexWithExceptionsAndConditions(selector.term, exceptions, conditions, options).toArray();
|
||||
return {
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField({
|
||||
emailOrPhone,
|
||||
nameOrUsername,
|
||||
pagination: { offset, count, sort },
|
||||
}: {
|
||||
emailOrPhone?: string;
|
||||
nameOrUsername?: RegExp;
|
||||
pagination: { offset: number; count: number; sort: FindOptions<IVisitor>['sort'] };
|
||||
}) {
|
||||
const allowedCF = await LivechatCustomField.findMatchingCustomFields('visitor', true, { projection: { _id: 1 } })
|
||||
.map((cf) => cf._id)
|
||||
.toArray();
|
||||
|
||||
const { cursor, totalCount } = await LivechatVisitors.findPaginatedVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField(
|
||||
emailOrPhone,
|
||||
nameOrUsername,
|
||||
allowedCF,
|
||||
{
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
projection: {
|
||||
username: 1,
|
||||
name: 1,
|
||||
phone: 1,
|
||||
livechatData: 1,
|
||||
visitorEmails: 1,
|
||||
lastChat: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [visitors, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return {
|
||||
visitors,
|
||||
count: visitors.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
316
apps/meteor/server/api/v1/omnichannel/message.ts
Normal file
316
apps/meteor/server/api/v1/omnichannel/message.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { OmnichannelSourceType } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors, LivechatRooms, Messages } from '@rocket.chat/models';
|
||||
import { registerGuest } from '@rocket.chat/omni-core';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import {
|
||||
isPOSTLivechatMessageParams,
|
||||
isGETLivechatMessageIdParams,
|
||||
isPUTLivechatMessageIdParams,
|
||||
isDELETELivechatMessageIdParams,
|
||||
isGETLivechatMessagesHistoryRidParams,
|
||||
isGETLivechatMessagesParams,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findGuest, findRoom, normalizeHttpHeaderData } from './lib/livechat';
|
||||
import { callbacks } from '../../../lib/callbacks';
|
||||
import { loadMessageHistory } from '../../../lib/messages/loadMessageHistory';
|
||||
import { updateMessage, deleteMessage, sendMessage } from '../../../lib/omnichannel/messages';
|
||||
import { normalizeMessageFileUpload } from '../../../lib/utils/functions/normalizeMessageFileUpload';
|
||||
import { settings } from '../../../settings';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
import { isWidget } from '../../lib/isWidget';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/message',
|
||||
{ validateParams: isPOSTLivechatMessageParams },
|
||||
{
|
||||
async post() {
|
||||
const { token, rid, agent, msg } = this.bodyParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
throw new Error('room-closed');
|
||||
}
|
||||
|
||||
if (
|
||||
settings.get('Livechat_enable_message_character_limit') &&
|
||||
msg.length > parseInt(settings.get('Livechat_message_character_limit'))
|
||||
) {
|
||||
throw new Error('message-length-exceeds-character-limit');
|
||||
}
|
||||
|
||||
const _id = this.bodyParams._id || Random.id();
|
||||
|
||||
const messageToSend = {
|
||||
guest,
|
||||
message: {
|
||||
_id,
|
||||
rid,
|
||||
msg,
|
||||
token,
|
||||
},
|
||||
agent,
|
||||
roomInfo: {
|
||||
source: {
|
||||
type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sendMessage(messageToSend);
|
||||
if (result) {
|
||||
const message = await Messages.findOneById(_id);
|
||||
if (!message) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
return API.v1.success({ message });
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/message/:_id',
|
||||
{ validateParams: { GET: isGETLivechatMessageIdParams, PUT: isPUTLivechatMessageIdParams, DELETE: isDELETELivechatMessageIdParams } },
|
||||
{
|
||||
async get() {
|
||||
const { token, rid } = this.queryParams;
|
||||
const { _id } = this.urlParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
let message = await Messages.findOneByRoomIdAndMessageId(rid, _id);
|
||||
if (!message) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
if (message.file) {
|
||||
message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } };
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
return API.v1.success({ message });
|
||||
},
|
||||
|
||||
async put() {
|
||||
const { token, rid } = this.bodyParams;
|
||||
const { _id } = this.urlParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
// TODO: projection
|
||||
const msg = await Messages.findOneById(_id);
|
||||
if (!msg) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
const result = await updateMessage({
|
||||
guest,
|
||||
message: { _id: msg._id, msg: this.bodyParams.msg, rid: msg.rid },
|
||||
});
|
||||
if (!result) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
let message = await Messages.findOneById(_id);
|
||||
if (!message) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
if (message?.file) {
|
||||
message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } };
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
return API.v1.success({ message });
|
||||
},
|
||||
async delete() {
|
||||
const { token, rid } = this.bodyParams;
|
||||
const { _id } = this.urlParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
const message = await Messages.findOneById(_id);
|
||||
if (!message) {
|
||||
throw new Error('invalid-message');
|
||||
}
|
||||
|
||||
const result = await deleteMessage({ guest, message });
|
||||
if (result) {
|
||||
return API.v1.success({
|
||||
message: {
|
||||
_id,
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/messages.history/:rid',
|
||||
{ validateParams: isGETLivechatMessagesHistoryRidParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset } = await getPaginationItems(this.queryParams);
|
||||
const { token } = this.queryParams;
|
||||
const { rid } = this.urlParams;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('error-token-param-not-provided');
|
||||
}
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
let ls = undefined;
|
||||
if (this.queryParams.ls) {
|
||||
ls = new Date(this.queryParams.ls);
|
||||
}
|
||||
|
||||
let end = undefined;
|
||||
if (this.queryParams.end) {
|
||||
end = new Date(this.queryParams.end);
|
||||
}
|
||||
|
||||
let limit = 20;
|
||||
if (this.queryParams.limit) {
|
||||
limit = parseInt(`${this.queryParams.limit}`, 10);
|
||||
}
|
||||
|
||||
const history = await loadMessageHistory({
|
||||
userId: guest._id,
|
||||
rid,
|
||||
end,
|
||||
limit,
|
||||
ls,
|
||||
offset,
|
||||
});
|
||||
|
||||
const messages = await Promise.all(history.messages.map((message) => normalizeMessageFileUpload(message)));
|
||||
|
||||
return API.v1.success({ messages });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/messages',
|
||||
{ authRequired: true, validateParams: isGETLivechatMessagesParams },
|
||||
{
|
||||
async post() {
|
||||
const visitorToken = this.bodyParams.visitor.token;
|
||||
|
||||
let visitor = await LivechatVisitors.getVisitorByToken(visitorToken, {});
|
||||
let rid: string;
|
||||
if (visitor) {
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
const room = await LivechatRooms.findOneOpenByVisitorToken(visitorToken, { projection: { _id: 1 } }, extraQuery);
|
||||
rid = room?._id ?? Random.id();
|
||||
} else {
|
||||
rid = Random.id();
|
||||
|
||||
const guest: typeof this.bodyParams.visitor & { connectionData?: unknown } = this.bodyParams.visitor;
|
||||
|
||||
if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) {
|
||||
guest.connectionData = normalizeHttpHeaderData(this.request.headers);
|
||||
}
|
||||
|
||||
visitor = await registerGuest(guest, {
|
||||
shouldConsiderIdleAgent: settings.get<boolean>('Livechat_enabled_when_agent_idle'),
|
||||
shouldConsiderOfflineAgent: settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
|
||||
});
|
||||
if (!visitor) {
|
||||
throw new Error('error-livechat-visitor-registration');
|
||||
}
|
||||
}
|
||||
|
||||
const guest = visitor;
|
||||
if (!guest) {
|
||||
throw new Error('error-invalid-token');
|
||||
}
|
||||
|
||||
const sentMessages = await Promise.all(
|
||||
this.bodyParams.messages.map(async (message: { msg: string }): Promise<{ username: string; msg: string; ts: number }> => {
|
||||
const messageToSend = {
|
||||
guest,
|
||||
message: {
|
||||
_id: Random.id(),
|
||||
rid,
|
||||
token: visitorToken,
|
||||
msg: message.msg,
|
||||
},
|
||||
roomInfo: {
|
||||
source: {
|
||||
type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sentMessage = await sendMessage(messageToSend);
|
||||
return {
|
||||
username: sentMessage.u.username,
|
||||
msg: sentMessage.msg,
|
||||
ts: sentMessage.ts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return API.v1.success({
|
||||
messages: sentMessages,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
24
apps/meteor/server/api/v1/omnichannel/offlineMessage.ts
Normal file
24
apps/meteor/server/api/v1/omnichannel/offlineMessage.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { isPOSTLivechatOfflineMessageParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { i18n } from '../../../lib/i18n';
|
||||
import { sendOfflineMessage } from '../../../lib/omnichannel/messages';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/offline.message',
|
||||
{
|
||||
validateParams: isPOSTLivechatOfflineMessageParams,
|
||||
rateLimiterOptions: { numRequestsAllowed: 1, intervalTimeInMS: 5000 },
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { name, email, message, department, host } = this.bodyParams;
|
||||
try {
|
||||
await sendOfflineMessage({ name, email, message, department, host });
|
||||
return API.v1.success({ message: i18n.t('Livechat_offline_message_sent') });
|
||||
} catch (e) {
|
||||
return API.v1.failure(i18n.t('Error_sending_livechat_offline_message'));
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
23
apps/meteor/server/api/v1/omnichannel/pageVisited.ts
Normal file
23
apps/meteor/server/api/v1/omnichannel/pageVisited.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { IOmnichannelSystemMessage } from '@rocket.chat/core-typings';
|
||||
import { isPOSTLivechatPageVisitedParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { savePageHistory } from '../../../lib/omnichannel/tracking';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/page.visited',
|
||||
{ validateParams: isPOSTLivechatPageVisitedParams },
|
||||
{
|
||||
async post() {
|
||||
const { token, rid, pageInfo } = this.bodyParams;
|
||||
|
||||
const message = await savePageHistory(token, rid, pageInfo);
|
||||
if (!message) {
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
const { msg, navigation } = message as IOmnichannelSystemMessage;
|
||||
return API.v1.success({ page: { msg, navigation } });
|
||||
},
|
||||
},
|
||||
);
|
||||
29
apps/meteor/server/api/v1/omnichannel/queue.ts
Normal file
29
apps/meteor/server/api/v1/omnichannel/queue.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { isGETLivechatQueueParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findQueueMetrics } from './lib/queue';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/queue',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatQueueParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { agentId, includeOfflineAgents, departmentId } = this.queryParams;
|
||||
const users = await findQueueMetrics({
|
||||
agentId,
|
||||
includeOfflineAgents: includeOfflineAgents === 'true',
|
||||
departmentId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(users);
|
||||
},
|
||||
},
|
||||
);
|
||||
541
apps/meteor/server/api/v1/omnichannel/room.ts
Normal file
541
apps/meteor/server/api/v1/omnichannel/room.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import { Omnichannel } from '@rocket.chat/core-services';
|
||||
import type {
|
||||
ILivechatAgent,
|
||||
IOmnichannelInquiryExtraData,
|
||||
IOmnichannelRoom,
|
||||
IUser,
|
||||
SelectedAgent,
|
||||
TransferByData,
|
||||
TransferData,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors, Users, LivechatRooms } from '@rocket.chat/models';
|
||||
import {
|
||||
isLiveChatRoomForwardProps,
|
||||
isPOSTLivechatRoomCloseParams,
|
||||
isPOSTLivechatRoomSurveyParams,
|
||||
isLiveChatRoomJoinProps,
|
||||
isLiveChatRoomSaveInfoProps,
|
||||
isPOSTLivechatRoomCloseByUserParams,
|
||||
isPOSTLivechatRoomsCloseAll,
|
||||
isPOSTLivechatRoomsCloseAllSuccessResponse,
|
||||
POSTLivechatRemoveRoomSuccess,
|
||||
isPOSTLivechatRemoveRoomParams,
|
||||
validateBadRequestErrorResponse,
|
||||
validateUnauthorizedErrorResponse,
|
||||
validateForbiddenErrorResponse,
|
||||
ajv,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { isPOSTLivechatVisitorDepartmentTransferParams } from '@rocket.chat/rest-typings/src/v1/omnichannel';
|
||||
import { check, Match } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findGuest, findRoom, settings, findAgent, onCheckRoomParams } from './lib/livechat';
|
||||
import { canAccessRoomAsync } from '../../../lib/authorization';
|
||||
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { callbacks } from '../../../lib/callbacks';
|
||||
import { i18n } from '../../../lib/i18n';
|
||||
import { normalizeTransferredByData } from '../../../lib/omnichannel/Helper';
|
||||
import { closeLivechatRoom } from '../../../lib/omnichannel/closeLivechatRoom';
|
||||
import { closeRoom } from '../../../lib/omnichannel/closeRoom';
|
||||
import { saveGuest } from '../../../lib/omnichannel/guests';
|
||||
import type { CloseRoomParams } from '../../../lib/omnichannel/localTypes';
|
||||
import { livechatLogger } from '../../../lib/omnichannel/logger';
|
||||
import { createRoom, removeOmnichannelRoom, saveRoomInfo } from '../../../lib/omnichannel/rooms';
|
||||
import { transfer } from '../../../lib/omnichannel/transfer';
|
||||
import { addUserToRoom } from '../../../lib/rooms/addUserToRoom';
|
||||
import { settings as rcSettings } from '../../../settings';
|
||||
import type { ExtractRoutesFromAPI } from '../../ApiClass';
|
||||
import { isWidget } from '../../lib/isWidget';
|
||||
|
||||
const isAgentWithInfo = (agentObj: ILivechatAgent | { hiddenInfo: boolean }): agentObj is ILivechatAgent => !('hiddenInfo' in agentObj);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room',
|
||||
{
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 5,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
// I'll temporary use check for validation, as validateParams doesnt support what's being done here
|
||||
const extraCheckParams = onCheckRoomParams({
|
||||
token: String,
|
||||
rid: Match.Maybe(String),
|
||||
agentId: Match.Maybe(String),
|
||||
});
|
||||
|
||||
check(this.queryParams, extraCheckParams);
|
||||
|
||||
const { token, rid, agentId, ...extraParams } = this.queryParams;
|
||||
|
||||
const guest = token && (await findGuest(token));
|
||||
if (!guest) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
if (!rid) {
|
||||
const room = await LivechatRooms.findOneOpenByVisitorToken(token, {});
|
||||
if (room) {
|
||||
return API.v1.success({ room, newRoom: false });
|
||||
}
|
||||
|
||||
let agent: SelectedAgent | undefined;
|
||||
const agentObj = agentId && (await findAgent(agentId));
|
||||
if (agentObj) {
|
||||
if (isAgentWithInfo(agentObj)) {
|
||||
const { username = undefined } = agentObj;
|
||||
agent = { agentId, username };
|
||||
} else {
|
||||
agent = { agentId };
|
||||
}
|
||||
}
|
||||
|
||||
const roomInfo = {
|
||||
source: {
|
||||
...(isWidget(this.request.headers)
|
||||
? { type: OmnichannelSourceType.WIDGET, destination: this.request.headers.get('host')! }
|
||||
: { type: OmnichannelSourceType.API }),
|
||||
},
|
||||
};
|
||||
|
||||
const newRoom = await createRoom({
|
||||
visitor: guest,
|
||||
roomInfo,
|
||||
agent,
|
||||
extraData: extraParams as IOmnichannelInquiryExtraData,
|
||||
});
|
||||
|
||||
return API.v1.success({
|
||||
room: newRoom,
|
||||
newRoom: true,
|
||||
});
|
||||
}
|
||||
|
||||
const froom = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(rid, token, {});
|
||||
if (!froom) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
return API.v1.success({ room: froom, newRoom: false });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Note: use this route if a visitor is closing a room
|
||||
// If a RC user(like eg agent) is closing a room, use the `livechat/room.closeByUser` route
|
||||
API.v1.addRoute(
|
||||
'livechat/room.close',
|
||||
{ validateParams: isPOSTLivechatRoomCloseParams },
|
||||
{
|
||||
async post() {
|
||||
const { rid, token } = this.bodyParams;
|
||||
|
||||
if (!rcSettings.get('Omnichannel_allow_visitors_to_close_conversation')) {
|
||||
throw new Error('error-not-allowed-to-close-conversation');
|
||||
}
|
||||
|
||||
const visitor = await findGuest(token);
|
||||
if (!visitor) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
throw new Error('room-closed');
|
||||
}
|
||||
|
||||
const language = rcSettings.get<string>('Language') || 'en';
|
||||
const comment = i18n.t('Closed_by_visitor', { lng: language });
|
||||
|
||||
const options: CloseRoomParams['options'] = {};
|
||||
if (room.servedBy) {
|
||||
const servingAgent: Pick<IUser, '_id' | 'name' | 'username' | 'utcOffset' | 'settings' | 'language'> | null =
|
||||
await Users.findOneById(room.servedBy._id, {
|
||||
projection: {
|
||||
name: 1,
|
||||
username: 1,
|
||||
utcOffset: 1,
|
||||
settings: 1,
|
||||
language: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (servingAgent?.settings?.preferences?.omnichannelTranscriptPDF) {
|
||||
options.pdfTranscript = {
|
||||
requestedBy: servingAgent._id,
|
||||
};
|
||||
}
|
||||
|
||||
// We'll send the transcript by email only if the setting is disabled (that means, we're not asking the user if he wants to receive the transcript by email)
|
||||
// And the agent has the preference enabled to send the transcript by email and the visitor has an email address
|
||||
// When Livechat_enable_transcript is enabled, the email will be sent via livechat/transcript route
|
||||
if (
|
||||
!rcSettings.get<boolean>('Livechat_enable_transcript') &&
|
||||
servingAgent?.settings?.preferences?.omnichannelTranscriptEmail &&
|
||||
visitor.visitorEmails?.length &&
|
||||
visitor.visitorEmails?.[0]?.address
|
||||
) {
|
||||
const visitorEmail = visitor.visitorEmails?.[0]?.address;
|
||||
|
||||
const language = servingAgent.language || rcSettings.get<string>('Language') || 'en';
|
||||
const t = i18n.getFixedT(language);
|
||||
const subject = t('Transcript_of_your_livechat_conversation');
|
||||
|
||||
options.emailTranscript = {
|
||||
sendToVisitor: true,
|
||||
requestData: {
|
||||
email: visitorEmail,
|
||||
requestedAt: new Date(),
|
||||
requestedBy: servingAgent,
|
||||
subject,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await closeRoom({ visitor, room, comment, options });
|
||||
|
||||
return API.v1.success({ rid, comment });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.closeByUser',
|
||||
{
|
||||
validateParams: isPOSTLivechatRoomCloseByUserParams,
|
||||
authRequired: true,
|
||||
permissionsRequired: ['close-livechat-room'],
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
const { rid, comment, tags, generateTranscriptPdf, transcriptEmail, forceClose } = this.bodyParams;
|
||||
|
||||
const allowForceClose = rcSettings.get<boolean>('Omnichannel_allow_force_close_conversations');
|
||||
const isForceClosing = allowForceClose && forceClose;
|
||||
|
||||
if (isForceClosing) {
|
||||
livechatLogger.warn({ msg: 'Force closing a conversation', user: this.userId, room: rid });
|
||||
}
|
||||
|
||||
await closeLivechatRoom(this.user, rid, {
|
||||
comment,
|
||||
tags,
|
||||
generateTranscriptPdf,
|
||||
transcriptEmail,
|
||||
forceClose: isForceClosing,
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.survey',
|
||||
{ validateParams: isPOSTLivechatRoomSurveyParams },
|
||||
{
|
||||
async post() {
|
||||
const { rid, token, data } = this.bodyParams;
|
||||
|
||||
const visitor = await findGuest(token);
|
||||
if (!visitor) {
|
||||
throw new Error('invalid-token');
|
||||
}
|
||||
|
||||
const room = await findRoom(token, rid);
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
const config = await settings();
|
||||
if (!config.survey?.items || !config.survey.values) {
|
||||
throw new Error('invalid-livechat-config');
|
||||
}
|
||||
|
||||
const updateData: { [k: string]: string } = {};
|
||||
for (const item of data) {
|
||||
if ((config.survey.items.includes(item.name) && config.survey.values.includes(item.value)) || item.name === 'additionalFeedback') {
|
||||
updateData[item.name] = item.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
throw new Error('invalid-data');
|
||||
}
|
||||
|
||||
if (!(await LivechatRooms.updateSurveyFeedbackById(room._id, updateData))) {
|
||||
return API.v1.failure();
|
||||
}
|
||||
|
||||
return API.v1.success({ rid, data: updateData });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.forward',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room', 'transfer-livechat-guest'], validateParams: isLiveChatRoomForwardProps },
|
||||
{
|
||||
async post() {
|
||||
const transferData = this.bodyParams as typeof this.bodyParams & {
|
||||
transferredBy: TransferByData;
|
||||
transferredTo?: { _id: string; username?: string; name?: string };
|
||||
};
|
||||
|
||||
const room = await LivechatRooms.findOneById(this.bodyParams.roomId);
|
||||
if (room?.t !== 'l') {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
throw new Error('This_conversation_is_already_closed');
|
||||
}
|
||||
|
||||
if (!(await Omnichannel.isWithinMACLimit(room))) {
|
||||
throw new Error('error-mac-limit-reached');
|
||||
}
|
||||
|
||||
const guest = await LivechatVisitors.findOneEnabledById(room.v?._id);
|
||||
if (!guest) {
|
||||
throw new Error('error-invalid-visitor');
|
||||
}
|
||||
|
||||
transferData.transferredBy = normalizeTransferredByData(this.user, room);
|
||||
if (transferData.userId) {
|
||||
const userToTransfer = await Users.findOneById(transferData.userId);
|
||||
if (userToTransfer) {
|
||||
transferData.transferredTo = {
|
||||
_id: userToTransfer._id,
|
||||
username: userToTransfer.username,
|
||||
name: userToTransfer.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const chatForwardedResult = await transfer(room, guest, transferData);
|
||||
if (!chatForwardedResult) {
|
||||
throw new Error('error-forwarding-chat');
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatVisitorDepartmentTransfer = API.v1.post(
|
||||
'livechat/visitor/department.transfer',
|
||||
{
|
||||
response: {
|
||||
200: ajv.compile<void>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
enum: [true],
|
||||
},
|
||||
},
|
||||
required: ['success'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
400: validateBadRequestErrorResponse,
|
||||
},
|
||||
body: isPOSTLivechatVisitorDepartmentTransferParams,
|
||||
},
|
||||
async function action() {
|
||||
const { rid, token, department } = this.bodyParams;
|
||||
|
||||
const visitor = await findGuest(token);
|
||||
if (!visitor) {
|
||||
return API.v1.failure('invalid-token');
|
||||
}
|
||||
const room = await LivechatRooms.findOneById(rid);
|
||||
|
||||
if (room?.t !== 'l') {
|
||||
return API.v1.failure('error-invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
return API.v1.failure('This_conversation_is_already_closed');
|
||||
}
|
||||
|
||||
// As this is a visitor endpoint, we should not show the mac limit error
|
||||
if (!(await Omnichannel.isWithinMACLimit(room))) {
|
||||
return API.v1.failure('error-transefing-chat');
|
||||
}
|
||||
|
||||
const guest = await LivechatVisitors.findOneEnabledById(room.v?._id);
|
||||
if (!guest) {
|
||||
return API.v1.failure('error-invalid-visitor');
|
||||
}
|
||||
|
||||
const transferredBy = normalizeTransferredByData(
|
||||
{ _id: guest._id, username: guest.username, name: guest.name, userType: 'visitor' },
|
||||
room,
|
||||
);
|
||||
|
||||
const transferData: TransferData = { transferredBy, departmentId: department };
|
||||
|
||||
const chatForwardedResult = await transfer(room, guest, transferData);
|
||||
if (!chatForwardedResult) {
|
||||
return API.v1.failure('error-transfering-chat');
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatAnalyticsEndpoints = ExtractRoutesFromAPI<typeof livechatVisitorDepartmentTransfer>;
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatAnalyticsEndpoints {}
|
||||
}
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.join',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomJoinProps },
|
||||
{
|
||||
async get() {
|
||||
const { roomId } = this.queryParams;
|
||||
|
||||
const { user } = this;
|
||||
|
||||
if (!user) {
|
||||
throw new Error('error-invalid-user');
|
||||
}
|
||||
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
|
||||
if (!room) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
if (!room.open) {
|
||||
throw new Error('room-closed');
|
||||
}
|
||||
|
||||
if (!(await Omnichannel.isWithinMACLimit(room))) {
|
||||
throw new Error('error-mac-limit-reached');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, user))) {
|
||||
throw new Error('error-not-allowed');
|
||||
}
|
||||
|
||||
await addUserToRoom(roomId, user);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/room.saveInfo',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomSaveInfoProps },
|
||||
{
|
||||
async post() {
|
||||
const { roomData, guestData } = this.bodyParams;
|
||||
const room = await LivechatRooms.findOneById(roomData._id);
|
||||
if (!room || !isOmnichannelRoom(room)) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
|
||||
if (
|
||||
(!room.servedBy || room.servedBy._id !== this.userId) &&
|
||||
!(await hasPermissionAsync(this.user, 'save-others-livechat-room-info'))
|
||||
) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
if (room.sms) {
|
||||
delete guestData.phone;
|
||||
}
|
||||
|
||||
// We want this both operations to be concurrent, so we have to go with Promise.allSettled
|
||||
const result = await Promise.allSettled([saveGuest(guestData, this.userId), saveRoomInfo(roomData)]);
|
||||
|
||||
const firstError = result.find((item) => item.status === 'rejected');
|
||||
if (firstError) {
|
||||
throw new Error(firstError.reason.error);
|
||||
}
|
||||
|
||||
await callbacks.run('livechat.saveInfo', await LivechatRooms.findOneById(roomData._id), {
|
||||
user: this.user,
|
||||
oldRoom: room,
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const livechatRoomsEndpoints = API.v1
|
||||
.post(
|
||||
'livechat/rooms.delete',
|
||||
{
|
||||
response: {
|
||||
200: POSTLivechatRemoveRoomSuccess,
|
||||
400: validateBadRequestErrorResponse,
|
||||
401: validateUnauthorizedErrorResponse,
|
||||
403: validateForbiddenErrorResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['remove-closed-livechat-room'],
|
||||
body: isPOSTLivechatRemoveRoomParams,
|
||||
},
|
||||
async function action() {
|
||||
const { roomId } = this.bodyParams;
|
||||
|
||||
try {
|
||||
await removeOmnichannelRoom(roomId);
|
||||
return API.v1.success();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Meteor.Error) {
|
||||
return API.v1.failure(error.reason);
|
||||
}
|
||||
|
||||
return API.v1.failure('error-removing-room');
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
'livechat/rooms.removeAllClosedRooms',
|
||||
{
|
||||
response: {
|
||||
200: isPOSTLivechatRoomsCloseAllSuccessResponse,
|
||||
},
|
||||
authRequired: true,
|
||||
permissionsRequired: ['remove-closed-livechat-rooms'],
|
||||
body: isPOSTLivechatRoomsCloseAll,
|
||||
},
|
||||
async function action() {
|
||||
livechatLogger.info({ msg: 'User is removing all closed rooms', userId: this.userId });
|
||||
|
||||
const params = this.bodyParams;
|
||||
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
const promises: Promise<void>[] = [];
|
||||
await LivechatRooms.findClosedRooms(params?.departmentIds, {}, extraQuery).forEach(({ _id }: IOmnichannelRoom) => {
|
||||
promises.push(removeOmnichannelRoom(_id));
|
||||
});
|
||||
await Promise.all(promises);
|
||||
|
||||
livechatLogger.info({ msg: 'User removed closed rooms', userId: this.userId, removedRooms: promises.length });
|
||||
return API.v1.success({ removedRooms: promises.length });
|
||||
},
|
||||
);
|
||||
|
||||
type LivechatRoomsEndpoints = ExtractRoutesFromAPI<typeof livechatRoomsEndpoints>;
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends LivechatRoomsEndpoints {}
|
||||
}
|
||||
92
apps/meteor/server/api/v1/omnichannel/rooms.ts
Normal file
92
apps/meteor/server/api/v1/omnichannel/rooms.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { LivechatRooms } from '@rocket.chat/models';
|
||||
import { isGETLivechatRoomsParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findRooms } from './lib/rooms';
|
||||
import { hasPermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
const validateDateParams = (property: string, date?: string) => {
|
||||
let parsedDate: { start?: string; end?: string } | undefined = undefined;
|
||||
if (date) {
|
||||
parsedDate = JSON.parse(date) as { start?: string; end?: string };
|
||||
}
|
||||
|
||||
if (parsedDate?.start && isNaN(Date.parse(parsedDate.start))) {
|
||||
throw new Error(`The "${property}.start" query parameter must be a valid date.`);
|
||||
}
|
||||
if (parsedDate?.end && isNaN(Date.parse(parsedDate.end))) {
|
||||
throw new Error(`The "${property}.end" query parameter must be a valid date.`);
|
||||
}
|
||||
return parsedDate;
|
||||
};
|
||||
|
||||
const isBoolean = (value?: string | boolean): boolean => value === 'true' || value === 'false' || typeof value === 'boolean';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/rooms',
|
||||
{ authRequired: true, validateParams: isGETLivechatRoomsParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort, fields, query } = await this.parseJsonQuery();
|
||||
const { agents, departmentId, open, tags, roomName, onhold, queued, units } = this.queryParams;
|
||||
const { createdAt, customFields, closedAt } = this.queryParams;
|
||||
|
||||
const createdAtParam = validateDateParams('createdAt', createdAt);
|
||||
const closedAtParam = validateDateParams('closedAt', closedAt);
|
||||
|
||||
const hasAdminAccess = await hasPermissionAsync(this.user, 'view-livechat-rooms');
|
||||
const hasAgentAccess = (await hasPermissionAsync(this.user, 'view-l-room')) && agents?.includes(this.userId) && agents?.length === 1;
|
||||
if (!hasAdminAccess && !hasAgentAccess) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
let parsedCf: { [key: string]: string } | undefined = undefined;
|
||||
if (customFields) {
|
||||
try {
|
||||
const parsedCustomFields = JSON.parse(customFields) as { [key: string]: string };
|
||||
if (typeof parsedCustomFields !== 'object' || Array.isArray(parsedCustomFields) || parsedCustomFields === null) {
|
||||
throw new Error('Invalid custom fields');
|
||||
}
|
||||
|
||||
// Model's already checking for the keys, so we don't need to do it here.
|
||||
parsedCf = parsedCustomFields;
|
||||
} catch (e) {
|
||||
throw new Error('The "customFields" query parameter must be a valid JSON.');
|
||||
}
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findRooms({
|
||||
agents,
|
||||
roomName,
|
||||
departmentId,
|
||||
...(isBoolean(open) && { open: open === true || open === 'true' }),
|
||||
createdAt: createdAtParam,
|
||||
closedAt: closedAtParam,
|
||||
tags,
|
||||
customFields: parsedCf,
|
||||
onhold,
|
||||
queued,
|
||||
units,
|
||||
query,
|
||||
options: { offset, count, sort, fields },
|
||||
callerId: this.userId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/rooms/filters',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'] },
|
||||
{
|
||||
async get() {
|
||||
return API.v1.success({
|
||||
filters: (await LivechatRooms.findAvailableSources().toArray())[0].fullTypes,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
286
apps/meteor/server/api/v1/omnichannel/sms.ts
Normal file
286
apps/meteor/server/api/v1/omnichannel/sms.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { OmnichannelIntegration } from '@rocket.chat/core-services';
|
||||
import type {
|
||||
ILivechatVisitor,
|
||||
IUpload,
|
||||
MessageAttachment,
|
||||
ServiceData,
|
||||
FileAttachmentProps,
|
||||
IOmnichannelRoomInfo,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { OmnichannelSourceType } from '@rocket.chat/core-typings';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
|
||||
import { registerGuest } from '@rocket.chat/omni-core';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { setCustomField } from './lib/customFields';
|
||||
import { getFileExtension } from '../../../../lib/utils/getFileExtension';
|
||||
import { FileUpload } from '../../../lib/media/file-upload';
|
||||
import type { ILivechatMessage } from '../../../lib/omnichannel/localTypes';
|
||||
import { sendMessage } from '../../../lib/omnichannel/messages';
|
||||
import { createRoom } from '../../../lib/omnichannel/rooms';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
const logger = new Logger('SMS');
|
||||
|
||||
const getUploadFile = async (details: Omit<IUpload, '_id' | '_updatedAt'>, fileUrl: string) => {
|
||||
const response = await fetch(fileUrl, {
|
||||
ignoreSsrfValidation: false,
|
||||
allowList: settings.get<string>('SSRF_Allowlist'),
|
||||
});
|
||||
|
||||
const content = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
const contentSize = content.length;
|
||||
|
||||
if (response.status !== 200 || contentSize === 0) {
|
||||
throw new Meteor.Error('error-invalid-file-uploaded', 'Invalid file uploaded');
|
||||
}
|
||||
|
||||
const fileStore = FileUpload.getStore('Uploads');
|
||||
|
||||
return fileStore.insert({ ...details, size: contentSize }, content);
|
||||
};
|
||||
|
||||
const defineDepartment = async (idOrName?: string) => {
|
||||
if (!idOrName || idOrName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const department = await LivechatDepartment.findOneByIdOrName(idOrName, { projection: { _id: 1 } });
|
||||
return department?._id;
|
||||
};
|
||||
|
||||
const defineVisitor = async (smsNumber: string, targetDepartment?: string) => {
|
||||
const visitor = await LivechatVisitors.findOneVisitorByPhone(smsNumber);
|
||||
let data: { token: string; department?: string } = {
|
||||
token: visitor?.token || Random.id(),
|
||||
};
|
||||
|
||||
if (!visitor) {
|
||||
data = Object.assign(data, {
|
||||
username: smsNumber.replace(/[^0-9]/g, ''),
|
||||
phone: {
|
||||
number: smsNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (targetDepartment) {
|
||||
data.department = targetDepartment;
|
||||
}
|
||||
|
||||
const livechatVisitor = await registerGuest(data, {
|
||||
shouldConsiderIdleAgent: settings.get<boolean>('Livechat_enabled_when_agent_idle'),
|
||||
shouldConsiderOfflineAgent: settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
|
||||
});
|
||||
|
||||
if (!livechatVisitor) {
|
||||
throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor');
|
||||
}
|
||||
|
||||
return livechatVisitor;
|
||||
};
|
||||
|
||||
const normalizeLocationSharing = (payload: ServiceData) => {
|
||||
const { extra: { fromLatitude: latitude, fromLongitude: longitude } = {} } = payload;
|
||||
if (!latitude || !longitude) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Point',
|
||||
coordinates: [parseFloat(longitude), parseFloat(latitude)],
|
||||
};
|
||||
};
|
||||
|
||||
// @ts-expect-error - this is an special endpoint that requires the return to not be wrapped as regular returns
|
||||
API.v1.addRoute('livechat/sms-incoming/:service', {
|
||||
async post() {
|
||||
const { service } = this.urlParams;
|
||||
if (!(await OmnichannelIntegration.isConfiguredSmsService(service))) {
|
||||
return API.v1.failure('Invalid service');
|
||||
}
|
||||
|
||||
const smsDepartment = settings.get<string>('SMS_Default_Omnichannel_Department');
|
||||
const SMSService = await OmnichannelIntegration.getSmsService(service);
|
||||
|
||||
if (!(await SMSService.validateRequest(this.request.clone(), this.bodyParams))) {
|
||||
return API.v1.failure('Invalid request');
|
||||
}
|
||||
|
||||
const sms = SMSService.parse(this.bodyParams);
|
||||
const { department } = this.queryParams;
|
||||
let targetDepartment = await defineDepartment(department || smsDepartment);
|
||||
if (!targetDepartment) {
|
||||
targetDepartment = await defineDepartment(smsDepartment);
|
||||
}
|
||||
|
||||
const visitor = await defineVisitor(sms.from, targetDepartment);
|
||||
if (!visitor) {
|
||||
return API.v1.success(SMSService.error(new Error('Invalid visitor')));
|
||||
}
|
||||
|
||||
const roomInfo: IOmnichannelRoomInfo = {
|
||||
sms: {
|
||||
from: sms.to,
|
||||
},
|
||||
source: {
|
||||
type: OmnichannelSourceType.SMS,
|
||||
alias: service,
|
||||
destination: sms.to,
|
||||
},
|
||||
};
|
||||
|
||||
const { token } = visitor;
|
||||
const room =
|
||||
(await LivechatRooms.findOneOpenByVisitorTokenAndDepartmentIdAndSource(token, targetDepartment, OmnichannelSourceType.SMS)) ??
|
||||
(await createRoom({
|
||||
visitor,
|
||||
roomInfo,
|
||||
}));
|
||||
const location = normalizeLocationSharing(sms);
|
||||
const rid = room?._id;
|
||||
|
||||
let file: ILivechatMessage['file'];
|
||||
const attachments: (MessageAttachment | undefined)[] = [];
|
||||
|
||||
const [media] = sms?.media || [];
|
||||
if (media) {
|
||||
const { url: smsUrl, contentType } = media;
|
||||
const details = {
|
||||
name: 'Upload File',
|
||||
type: contentType,
|
||||
rid,
|
||||
visitorToken: token,
|
||||
};
|
||||
|
||||
try {
|
||||
const uploadedFile = await getUploadFile(details, smsUrl);
|
||||
file = { _id: uploadedFile._id, name: uploadedFile.name || 'file', type: uploadedFile.type };
|
||||
const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || 'file')}`);
|
||||
|
||||
const fileType = file.type as string;
|
||||
|
||||
if (/^image\/.+/.test(fileType)) {
|
||||
const attachment: FileAttachmentProps = {
|
||||
title: file.name,
|
||||
type: 'file',
|
||||
description: file.description,
|
||||
title_link: fileUrl,
|
||||
image_url: fileUrl,
|
||||
image_type: fileType,
|
||||
image_size: file.size,
|
||||
};
|
||||
|
||||
if (file.identify?.size) {
|
||||
attachment.image_dimensions = file?.identify.size;
|
||||
}
|
||||
|
||||
attachments.push(attachment);
|
||||
} else if (/^audio\/.+/.test(fileType)) {
|
||||
const attachment: FileAttachmentProps = {
|
||||
title: file.name,
|
||||
type: 'file',
|
||||
description: file.description,
|
||||
title_link: fileUrl,
|
||||
audio_url: fileUrl,
|
||||
audio_type: fileType,
|
||||
audio_size: file.size,
|
||||
title_link_download: true,
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
} else if (/^video\/.+/.test(fileType)) {
|
||||
const attachment: FileAttachmentProps = {
|
||||
title: file.name,
|
||||
type: 'file',
|
||||
description: file.description,
|
||||
title_link: fileUrl,
|
||||
video_url: fileUrl,
|
||||
video_type: fileType,
|
||||
video_size: file.size as number,
|
||||
title_link_download: true,
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
} else {
|
||||
const attachment = {
|
||||
title: file.name,
|
||||
type: 'file',
|
||||
description: file.description,
|
||||
format: getFileExtension(file.name),
|
||||
title_link: fileUrl,
|
||||
title_link_download: true,
|
||||
size: file.size as number,
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Attachment upload failed', err });
|
||||
const attachment = {
|
||||
title: 'Attachment upload failed',
|
||||
type: 'file',
|
||||
description: 'An attachment was received, but upload to server failed',
|
||||
fields: [
|
||||
{
|
||||
title: 'User upload failed',
|
||||
value: 'An attachment was received, but upload to server failed',
|
||||
short: true,
|
||||
},
|
||||
],
|
||||
color: 'yellow',
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
const messageToSend: {
|
||||
guest: ILivechatVisitor;
|
||||
message: ILivechatMessage;
|
||||
roomInfo: IOmnichannelRoomInfo;
|
||||
} = {
|
||||
guest: visitor,
|
||||
roomInfo,
|
||||
message: {
|
||||
_id: Random.id(),
|
||||
rid,
|
||||
token,
|
||||
msg: sms.body,
|
||||
...(location && { location }),
|
||||
...(attachments && { attachments: attachments.filter((a: any): a is MessageAttachment => !!a) }),
|
||||
...(file && { file }),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await sendMessage(messageToSend);
|
||||
const msg = SMSService.response();
|
||||
setImmediate(async () => {
|
||||
if (sms.extra) {
|
||||
if (sms.extra.fromCountry) {
|
||||
await setCustomField(messageToSend.message.token, 'country', sms.extra.fromCountry);
|
||||
}
|
||||
if (sms.extra.fromState) {
|
||||
await setCustomField(messageToSend.message.token, 'state', sms.extra.fromState);
|
||||
}
|
||||
if (sms.extra.fromCity) {
|
||||
await setCustomField(messageToSend.message.token, 'city', sms.extra.fromCity);
|
||||
}
|
||||
if (sms.extra.fromZip) {
|
||||
await setCustomField(messageToSend.message.token, 'zip', sms.extra.fromZip);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return msg;
|
||||
} catch (e: any) {
|
||||
return SMSService.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
67
apps/meteor/server/api/v1/omnichannel/statistics.ts
Normal file
67
apps/meteor/server/api/v1/omnichannel/statistics.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { isLivechatAnalyticsAgentOverviewProps, isLivechatAnalyticsOverviewProps } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { getAgentOverviewDataCached, getAnalyticsOverviewDataCached } from '../../../lib/omnichannel/AnalyticsTyped';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/agent-overview',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsAgentOverviewProps,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { name, departmentId, from, to } = this.queryParams;
|
||||
|
||||
if (!name) {
|
||||
throw new Error('invalid-chart-name');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { _id: 1, utcOffset: 1 } });
|
||||
return API.v1.success(
|
||||
await getAgentOverviewDataCached({
|
||||
departmentId,
|
||||
utcOffset: user?.utcOffset || 0,
|
||||
daterange: { from, to },
|
||||
chartOptions: { name },
|
||||
executedBy: this.userId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/analytics/overview',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: isLivechatAnalyticsOverviewProps,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { name, departmentId, from, to } = this.queryParams;
|
||||
|
||||
if (!name) {
|
||||
throw new Error('invalid-chart-name');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(this.userId, { projection: { _id: 1, utcOffset: 1, language: 1 } });
|
||||
const language = user?.language || settings.get('Language') || 'en';
|
||||
|
||||
return API.v1.success(
|
||||
await getAnalyticsOverviewDataCached({
|
||||
departmentId,
|
||||
utcOffset: user?.utcOffset || 0,
|
||||
daterange: { from, to },
|
||||
analyticsOptions: { name },
|
||||
language,
|
||||
executedBy: this.userId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
73
apps/meteor/server/api/v1/omnichannel/transcript.ts
Normal file
73
apps/meteor/server/api/v1/omnichannel/transcript.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Omnichannel } from '@rocket.chat/core-services';
|
||||
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatRooms, Users } from '@rocket.chat/models';
|
||||
import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { i18n } from '../../../lib/i18n';
|
||||
import { sendTranscript, requestTranscript } from '../../../lib/omnichannel/sendTranscript';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/transcript',
|
||||
{ validateParams: isPOSTLivechatTranscriptParams },
|
||||
{
|
||||
async post() {
|
||||
const { token, rid, email } = this.bodyParams;
|
||||
if (!(await sendTranscript({ token, rid, email }))) {
|
||||
return API.v1.failure({ message: i18n.t('Error_sending_livechat_transcript') });
|
||||
}
|
||||
|
||||
return API.v1.success({ message: i18n.t('Livechat_transcript_sent') });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/transcript/:rid',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['send-omnichannel-chat-transcript'],
|
||||
validateParams: {
|
||||
POST: isPOSTLivechatTranscriptRequestParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
async delete() {
|
||||
const { rid } = this.urlParams;
|
||||
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, 'open' | 'transcriptRequest' | 'v'>>(rid, {
|
||||
projection: { open: 1, transcriptRequest: 1, v: 1 },
|
||||
});
|
||||
|
||||
if (!room?.open) {
|
||||
throw new Error('error-invalid-room');
|
||||
}
|
||||
if (!room.transcriptRequest) {
|
||||
throw new Error('error-transcript-not-requested');
|
||||
}
|
||||
|
||||
if (!(await Omnichannel.isWithinMACLimit(room))) {
|
||||
throw new Error('error-mac-limit-reached');
|
||||
}
|
||||
|
||||
await LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid);
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
async post() {
|
||||
const { rid } = this.urlParams;
|
||||
const { email, subject } = this.bodyParams;
|
||||
|
||||
const user = await Users.findOneById(this.userId, {
|
||||
projection: { _id: 1, username: 1, name: 1, utcOffset: 1 },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('error-invalid-user');
|
||||
}
|
||||
|
||||
await requestTranscript({ rid, email, subject, user });
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
33
apps/meteor/server/api/v1/omnichannel/transfer.ts
Normal file
33
apps/meteor/server/api/v1/omnichannel/transfer.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { LivechatRooms } from '@rocket.chat/models';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findLivechatTransferHistory } from './lib/transfer';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/transfer.history/:rid',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-rooms'] },
|
||||
{
|
||||
async get() {
|
||||
const { rid } = this.urlParams;
|
||||
|
||||
const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1 } });
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const history = await findLivechatTransferHistory({
|
||||
rid,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(history);
|
||||
},
|
||||
},
|
||||
);
|
||||
68
apps/meteor/server/api/v1/omnichannel/triggers.ts
Normal file
68
apps/meteor/server/api/v1/omnichannel/triggers.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { LivechatTrigger } from '@rocket.chat/models';
|
||||
import { isGETLivechatTriggersParams, isPOSTLivechatTriggersParams } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findTriggers, findTriggerById, deleteTrigger } from './lib/triggers';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/triggers',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-livechat-manager'],
|
||||
validateParams: {
|
||||
GET: isGETLivechatTriggersParams,
|
||||
POST: isPOSTLivechatTriggersParams,
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const triggers = await findTriggers({
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(triggers);
|
||||
},
|
||||
async post() {
|
||||
const { _id, name, description, enabled, runOnce, conditions, actions } = this.bodyParams;
|
||||
|
||||
if (_id) {
|
||||
await LivechatTrigger.updateById(_id, { name, description, enabled, runOnce, conditions, actions });
|
||||
} else {
|
||||
await LivechatTrigger.insertOne({ name, description, enabled, runOnce, conditions, actions });
|
||||
}
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/triggers/:_id',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
|
||||
{
|
||||
async get() {
|
||||
const trigger = await findTriggerById({
|
||||
triggerId: this.urlParams._id,
|
||||
});
|
||||
|
||||
return API.v1.success({
|
||||
trigger,
|
||||
});
|
||||
},
|
||||
async delete() {
|
||||
await deleteTrigger({
|
||||
triggerId: this.urlParams._id,
|
||||
});
|
||||
|
||||
return API.v1.success();
|
||||
},
|
||||
},
|
||||
);
|
||||
75
apps/meteor/server/api/v1/omnichannel/upload.ts
Normal file
75
apps/meteor/server/api/v1/omnichannel/upload.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { LivechatVisitors, LivechatRooms } from '@rocket.chat/models';
|
||||
|
||||
import { API } from '../..';
|
||||
import { FileUpload } from '../../../lib/media/file-upload';
|
||||
import { fileUploadIsValidContentType } from '../../../lib/utils/restrictions';
|
||||
import { sendFileLivechatMessage } from '../../../meteor-methods/omnichannel/sendFileLivechatMessage';
|
||||
import { settings } from '../../../settings';
|
||||
import { MultipartUploadHandler } from '../../lib/MultipartUploadHandler';
|
||||
|
||||
API.v1.addRoute('livechat/upload/:rid', {
|
||||
async post() {
|
||||
if (!this.request.headers.get('x-visitor-token')) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const canUpload = settings.get<boolean>('Livechat_fileupload_enabled') && settings.get<boolean>('FileUpload_Enabled');
|
||||
|
||||
if (!canUpload) {
|
||||
return API.v1.failure({
|
||||
reason: 'error-file-upload-disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const visitorToken = this.request.headers.get('x-visitor-token');
|
||||
const visitor = await LivechatVisitors.getVisitorByToken(visitorToken as string, {});
|
||||
|
||||
if (!visitor) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const room = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(this.urlParams.rid, visitorToken as string);
|
||||
if (!room) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const maxFileSize = settings.get<number>('FileUpload_MaxFileSize') || 104857600;
|
||||
|
||||
const { file, fields } = await MultipartUploadHandler.parseRequest(this.request, {
|
||||
field: 'file',
|
||||
maxSize: maxFileSize > -1 ? maxFileSize : undefined,
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
return API.v1.failure({
|
||||
reason: 'error-no-file-uploaded',
|
||||
});
|
||||
}
|
||||
|
||||
if (!fileUploadIsValidContentType(file.mimetype)) {
|
||||
return API.v1.failure({
|
||||
reason: 'error-type-not-allowed',
|
||||
});
|
||||
}
|
||||
|
||||
const fileStore = FileUpload.getStore('Uploads');
|
||||
|
||||
const details = {
|
||||
name: file.filename,
|
||||
size: file.size,
|
||||
type: file.mimetype,
|
||||
rid: this.urlParams.rid,
|
||||
visitorToken,
|
||||
};
|
||||
|
||||
const uploadedFile = await fileStore.insert(details, file.tempFilePath);
|
||||
if (!uploadedFile) {
|
||||
return API.v1.failure('Invalid file');
|
||||
}
|
||||
|
||||
uploadedFile.description = fields.description;
|
||||
|
||||
delete fields.description;
|
||||
return API.v1.success(await sendFileLivechatMessage({ roomId: this.urlParams.rid, visitorToken, file: uploadedFile, msgData: fields }));
|
||||
},
|
||||
});
|
||||
126
apps/meteor/server/api/v1/omnichannel/users.ts
Normal file
126
apps/meteor/server/api/v1/omnichannel/users.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { isLivechatUsersManagerGETProps, isPOSTLivechatUsersTypeProps } from '@rocket.chat/rest-typings';
|
||||
import { check } from 'meteor/check';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findAgents, findManagers } from './lib/users';
|
||||
import { hasAtLeastOnePermissionAsync } from '../../../lib/authorization/hasPermission';
|
||||
import { addManager, addAgent, removeAgent, removeManager } from '../../../lib/omnichannel/omni-users';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
const emptyStringArray: string[] = [];
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/users/:type',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: {
|
||||
'POST': ['view-livechat-manager'],
|
||||
'*': emptyStringArray,
|
||||
},
|
||||
validateParams: {
|
||||
GET: isLivechatUsersManagerGETProps,
|
||||
POST: isPOSTLivechatUsersTypeProps,
|
||||
},
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
check(this.urlParams, {
|
||||
type: String,
|
||||
});
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { text } = this.queryParams;
|
||||
|
||||
if (this.urlParams.type === 'agent') {
|
||||
if (!(await hasAtLeastOnePermissionAsync(this.user, ['transfer-livechat-guest', 'edit-omnichannel-contact']))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
const { onlyAvailable, excludeId, showIdleAgents } = this.queryParams;
|
||||
return API.v1.success(
|
||||
await findAgents({
|
||||
text,
|
||||
onlyAvailable,
|
||||
excludeId,
|
||||
showIdleAgents,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (this.urlParams.type === 'manager') {
|
||||
if (!(await hasAtLeastOnePermissionAsync(this.user, ['view-livechat-manager']))) {
|
||||
return API.v1.forbidden();
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findManagers({
|
||||
text,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error('Invalid type');
|
||||
},
|
||||
async post() {
|
||||
if (this.urlParams.type === 'agent') {
|
||||
const user = await addAgent(this.bodyParams.username);
|
||||
if (user) {
|
||||
return API.v1.success({ user });
|
||||
}
|
||||
} else if (this.urlParams.type === 'manager') {
|
||||
const user = await addManager(this.bodyParams.username);
|
||||
if (user) {
|
||||
return API.v1.success({ user });
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/users/:type/:_id',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
|
||||
{
|
||||
async get() {
|
||||
if (!['agent', 'manager'].includes(this.urlParams.type)) {
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
const role = this.urlParams.type === 'agent' ? 'livechat-agent' : 'livechat-manager';
|
||||
|
||||
const user = await Users.findOneByIdAndRole(this.urlParams._id, role, {
|
||||
projection: { _id: 1, username: 1, name: 1, status: 1, statusLivechat: 1, emails: 1, livechat: 1 },
|
||||
});
|
||||
|
||||
// TODO: throw error instead of returning null
|
||||
return API.v1.success({ user });
|
||||
},
|
||||
async delete() {
|
||||
if (this.urlParams.type === 'agent') {
|
||||
if (await removeAgent(this.urlParams._id)) {
|
||||
return API.v1.success();
|
||||
}
|
||||
} else if (this.urlParams.type === 'manager') {
|
||||
if (await removeManager(this.urlParams._id)) {
|
||||
return API.v1.success();
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
|
||||
return API.v1.failure();
|
||||
},
|
||||
},
|
||||
);
|
||||
206
apps/meteor/server/api/v1/omnichannel/visitor.ts
Normal file
206
apps/meteor/server/api/v1/omnichannel/visitor.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors as VisitorsRaw, LivechatRooms } from '@rocket.chat/models';
|
||||
import { registerGuest } from '@rocket.chat/omni-core';
|
||||
import { Match, check } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { API } from '../..';
|
||||
import { findGuest, normalizeHttpHeaderData } from './lib/livechat';
|
||||
import { callbacks } from '../../../lib/callbacks';
|
||||
import { setMultipleVisitorCustomFields } from '../../../lib/omnichannel/custom-fields';
|
||||
import { notifyGuestStatusChanged, removeContactsByVisitorId } from '../../../lib/omnichannel/guests';
|
||||
import { livechatLogger } from '../../../lib/omnichannel/logger';
|
||||
import { saveRoomInfo } from '../../../lib/omnichannel/rooms';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitor',
|
||||
{
|
||||
rateLimiterOptions: {
|
||||
numRequestsAllowed: 5,
|
||||
intervalTimeInMS: 60000,
|
||||
},
|
||||
},
|
||||
{
|
||||
async post() {
|
||||
check(this.bodyParams, {
|
||||
visitor: Match.ObjectIncluding({
|
||||
token: String,
|
||||
name: Match.Maybe(String),
|
||||
email: Match.Maybe(String),
|
||||
department: Match.Maybe(String),
|
||||
phone: Match.Maybe(String),
|
||||
username: Match.Maybe(String),
|
||||
customFields: Match.Maybe([
|
||||
Match.ObjectIncluding({
|
||||
key: String,
|
||||
value: String,
|
||||
overwrite: Boolean,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { customFields, id, token, name, email, department, phone, username, connectionData } = this.bodyParams.visitor;
|
||||
|
||||
if (!token?.trim()) {
|
||||
throw new Meteor.Error('error-invalid-token', 'Token cannot be empty', { method: 'livechat/visitor' });
|
||||
}
|
||||
|
||||
const guest = {
|
||||
token,
|
||||
...(id && { id }),
|
||||
...(name && { name }),
|
||||
...(email && { email }),
|
||||
...(department && { department }),
|
||||
...(username && { username }),
|
||||
...(connectionData && { connectionData }),
|
||||
...(phone && typeof phone === 'string' && { phone: { number: phone as string } }),
|
||||
connectionData: normalizeHttpHeaderData(this.request.headers),
|
||||
};
|
||||
|
||||
const visitor = await registerGuest(guest, {
|
||||
shouldConsiderIdleAgent: settings.get<boolean>('Livechat_enabled_when_agent_idle'),
|
||||
shouldConsiderOfflineAgent: settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
|
||||
});
|
||||
if (!visitor) {
|
||||
throw new Meteor.Error('error-livechat-visitor-registration', 'Error registering visitor', {
|
||||
method: 'livechat/visitor',
|
||||
});
|
||||
}
|
||||
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
// If it's updating an existing visitor, it must also update the roomInfo
|
||||
const rooms = await LivechatRooms.findOpenByVisitorToken(visitor?.token, {}, extraQuery).toArray();
|
||||
await Promise.all(
|
||||
rooms.map(
|
||||
(room: IRoom) =>
|
||||
visitor &&
|
||||
saveRoomInfo(room, {
|
||||
_id: visitor._id,
|
||||
name: visitor.name,
|
||||
phone: visitor.phone?.[0]?.phoneNumber,
|
||||
livechatData: visitor.livechatData as { [k: string]: string },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (!Array.isArray(customFields) || !customFields.length) {
|
||||
return API.v1.success({ visitor });
|
||||
}
|
||||
|
||||
const result = await setMultipleVisitorCustomFields(visitor, customFields);
|
||||
|
||||
if (!result) {
|
||||
return API.v1.success({ visitor });
|
||||
}
|
||||
|
||||
return API.v1.success({ visitor: await VisitorsRaw.findOneEnabledById(visitor._id) });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute('livechat/visitor/:token', {
|
||||
async get() {
|
||||
check(this.urlParams, {
|
||||
token: String,
|
||||
});
|
||||
|
||||
const visitor = await VisitorsRaw.getVisitorByToken(this.urlParams.token, {});
|
||||
|
||||
if (!visitor) {
|
||||
throw new Meteor.Error('invalid-token');
|
||||
}
|
||||
|
||||
return API.v1.success({ visitor });
|
||||
},
|
||||
async delete() {
|
||||
check(this.urlParams, {
|
||||
token: String,
|
||||
});
|
||||
|
||||
const visitor = await VisitorsRaw.getVisitorByToken(this.urlParams.token, {});
|
||||
if (!visitor) {
|
||||
throw new Meteor.Error('invalid-token');
|
||||
}
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
const rooms = await LivechatRooms.findOpenByVisitorToken(
|
||||
this.urlParams.token,
|
||||
{
|
||||
projection: {
|
||||
name: 1,
|
||||
t: 1,
|
||||
cl: 1,
|
||||
u: 1,
|
||||
usernames: 1,
|
||||
servedBy: 1,
|
||||
},
|
||||
},
|
||||
extraQuery,
|
||||
).toArray();
|
||||
|
||||
// if gdpr is enabled, bypass rooms check
|
||||
if (rooms?.length && !settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) {
|
||||
throw new Meteor.Error('visitor-has-open-rooms', 'Cannot remove visitors with opened rooms');
|
||||
}
|
||||
|
||||
const { _id } = visitor;
|
||||
try {
|
||||
await removeContactsByVisitorId({ _id });
|
||||
return API.v1.success({
|
||||
visitor: {
|
||||
_id,
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
livechatLogger.error({ msg: 'Error removing visitor', err: e });
|
||||
throw new Meteor.Error('error-removing-visitor', 'An error ocurred while deleting visitor');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitor/:token/room',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-manager'] },
|
||||
{
|
||||
async get() {
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: this.userId });
|
||||
const rooms = await LivechatRooms.findOpenByVisitorToken(
|
||||
this.urlParams.token,
|
||||
{
|
||||
projection: {
|
||||
name: 1,
|
||||
t: 1,
|
||||
cl: 1,
|
||||
u: 1,
|
||||
usernames: 1,
|
||||
servedBy: 1,
|
||||
},
|
||||
},
|
||||
extraQuery,
|
||||
).toArray();
|
||||
return API.v1.success({ rooms });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute('livechat/visitor.status', {
|
||||
async post() {
|
||||
check(this.bodyParams, {
|
||||
token: String,
|
||||
status: String,
|
||||
});
|
||||
|
||||
const { token, status } = this.bodyParams;
|
||||
|
||||
const guest = await findGuest(token);
|
||||
if (!guest) {
|
||||
throw new Meteor.Error('invalid-token');
|
||||
}
|
||||
|
||||
await notifyGuestStatusChanged(token, status);
|
||||
|
||||
return API.v1.success({ token, status });
|
||||
},
|
||||
});
|
||||
201
apps/meteor/server/api/v1/omnichannel/visitors.ts
Normal file
201
apps/meteor/server/api/v1/omnichannel/visitors.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Messages, LivechatRooms } from '@rocket.chat/models';
|
||||
import {
|
||||
isLivechatVisitorsInfoProps,
|
||||
isGETLivechatVisitorsPagesVisitedRoomIdParams,
|
||||
isGETLivechatVisitorsChatHistoryRoomRoomIdVisitorVisitorIdParams,
|
||||
isGETLivechatVisitorsSearchChatsRoomRoomIdVisitorVisitorIdParams,
|
||||
isGETLivechatVisitorsAutocompleteParams,
|
||||
isLivechatRidMessagesProps,
|
||||
isGETLivechatVisitorsSearch,
|
||||
} from '@rocket.chat/rest-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
import { API } from '../..';
|
||||
import {
|
||||
findVisitorInfo,
|
||||
findVisitedPages,
|
||||
findChatHistory,
|
||||
searchChats,
|
||||
findVisitorsToAutocomplete,
|
||||
findVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField,
|
||||
} from './lib/visitors';
|
||||
import { canAccessRoomAsync } from '../../../lib/authorization';
|
||||
import { apiDeprecationLogger } from '../../../lib/deprecationWarningLogger';
|
||||
import { normalizeMessagesForUser } from '../../../lib/utils/lib/normalizeMessagesForUser';
|
||||
import { getPaginationItems } from '../../lib/getPaginationItems';
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.info',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatVisitorsInfoProps },
|
||||
{
|
||||
async get() {
|
||||
const visitor = await findVisitorInfo({ visitorId: this.queryParams.visitorId });
|
||||
return API.v1.success(visitor);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.pagesVisited/:roomId',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatVisitorsPagesVisitedRoomIdParams },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const pages = await findVisitedPages({
|
||||
roomId: this.urlParams.roomId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
return API.v1.success(pages);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.chatHistory/room/:roomId/visitor/:visitorId',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
validateParams: isGETLivechatVisitorsChatHistoryRoomRoomIdVisitorVisitorIdParams,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const history = await findChatHistory({
|
||||
userId: this.userId,
|
||||
roomId: this.urlParams.roomId,
|
||||
visitorId: this.urlParams.visitorId,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
|
||||
return API.v1.success(history);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.searchChats/room/:roomId/visitor/:visitorId',
|
||||
{
|
||||
authRequired: true,
|
||||
permissionsRequired: ['view-l-room'],
|
||||
validateParams: isGETLivechatVisitorsSearchChatsRoomRoomIdVisitorVisitorIdParams,
|
||||
},
|
||||
{
|
||||
async get() {
|
||||
const { roomId, visitorId } = this.urlParams;
|
||||
const { searchText, closedChatsOnly, servedChatsOnly, source } = this.queryParams;
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const history = await searchChats({
|
||||
userId: this.userId,
|
||||
roomId,
|
||||
visitorId,
|
||||
searchText,
|
||||
closedChatsOnly,
|
||||
servedChatsOnly,
|
||||
source,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
return API.v1.success(history);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.autocomplete',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatVisitorsAutocompleteParams },
|
||||
{
|
||||
async get() {
|
||||
const { selector } = this.queryParams;
|
||||
const parsedSelector = JSON.parse(selector);
|
||||
|
||||
if (parsedSelector.conditions && Object.keys(parsedSelector.conditions).length > 0) {
|
||||
apiDeprecationLogger.parameter(this.route, 'conditions', '9.0.0', this.response);
|
||||
}
|
||||
|
||||
return API.v1.success(
|
||||
await findVisitorsToAutocomplete({
|
||||
selector: parsedSelector,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/visitors.search',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isGETLivechatVisitorsSearch },
|
||||
{
|
||||
async get() {
|
||||
const { term } = this.queryParams;
|
||||
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
|
||||
const nameOrUsername = term ? new RegExp(escapeRegExp(term), 'i') : undefined;
|
||||
|
||||
return API.v1.success(
|
||||
await findVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField({
|
||||
emailOrPhone: term,
|
||||
nameOrUsername,
|
||||
pagination: {
|
||||
offset,
|
||||
count,
|
||||
sort,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/:rid/messages',
|
||||
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLivechatRidMessagesProps },
|
||||
{
|
||||
async get() {
|
||||
const { offset, count } = await getPaginationItems(this.queryParams);
|
||||
const { sort } = await this.parseJsonQuery();
|
||||
const { searchTerm } = this.queryParams;
|
||||
|
||||
const room = await LivechatRooms.findOneById(this.urlParams.rid);
|
||||
|
||||
if (!room) {
|
||||
throw new Error('invalid-room');
|
||||
}
|
||||
|
||||
if (!(await canAccessRoomAsync(room, this.user))) {
|
||||
throw new Error('not-allowed');
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = Messages.findLivechatClosedMessages(this.urlParams.rid, searchTerm, {
|
||||
sort: sort || { ts: -1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [messages, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return API.v1.success({
|
||||
messages: await normalizeMessagesForUser(messages, this.userId),
|
||||
offset,
|
||||
count,
|
||||
total,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
95
apps/meteor/server/api/v1/omnichannel/webhooks.ts
Normal file
95
apps/meteor/server/api/v1/omnichannel/webhooks.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch';
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
|
||||
import { API } from '../..';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
const logger = new Logger('WebhookTest');
|
||||
|
||||
API.v1.addRoute(
|
||||
'livechat/webhook.test',
|
||||
{ authRequired: true, permissionsRequired: ['view-livechat-webhooks'] },
|
||||
{
|
||||
async post() {
|
||||
const sampleData = {
|
||||
type: 'LivechatSession',
|
||||
_id: 'fasd6f5a4sd6f8a4sdf',
|
||||
label: 'title',
|
||||
topic: 'asiodojf',
|
||||
createdAt: new Date(),
|
||||
lastMessageAt: new Date(),
|
||||
tags: ['tag1', 'tag2', 'tag3'],
|
||||
customFields: {
|
||||
productId: '123456',
|
||||
},
|
||||
visitor: {
|
||||
_id: '',
|
||||
name: 'visitor name',
|
||||
username: 'visitor-username',
|
||||
department: 'department',
|
||||
email: 'email@address.com',
|
||||
phone: '192873192873',
|
||||
ip: '123.456.7.89',
|
||||
browser: 'Chrome',
|
||||
os: 'Linux',
|
||||
customFields: {
|
||||
customerId: '123456',
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
_id: 'asdf89as6df8',
|
||||
username: 'agent.username',
|
||||
name: 'Agent Name',
|
||||
email: 'agent@email.com',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
username: 'visitor-username',
|
||||
msg: 'message content',
|
||||
ts: new Date(),
|
||||
},
|
||||
{
|
||||
username: 'agent.username',
|
||||
agentId: 'asdf89as6df8',
|
||||
msg: 'message content from agent',
|
||||
ts: new Date(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-RocketChat-Livechat-Token': settings.get<string>('Livechat_secret_token'),
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: sampleData,
|
||||
// SECURITY: Webhooks can only be configured by users with enough privileges. It's ok to disable this check here.
|
||||
ignoreSsrfValidation: true,
|
||||
size: 10 * 1024 * 1024,
|
||||
} as ExtendedFetchOptions;
|
||||
|
||||
const webhookUrl = settings.get<string>('Livechat_webhookUrl');
|
||||
|
||||
if (!webhookUrl) {
|
||||
return API.v1.failure('Webhook_URL_not_set');
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug({ msg: 'Testing webhook', webhookUrl });
|
||||
const request = await fetch(webhookUrl, options);
|
||||
const response = await request.text();
|
||||
|
||||
logger.debug({ msg: 'Webhook response', response });
|
||||
if (request.status === 200) {
|
||||
return API.v1.success();
|
||||
}
|
||||
|
||||
throw new Error('Invalid status code');
|
||||
} catch (error) {
|
||||
logger.error({ msg: 'Error testing webhook', err: error });
|
||||
throw new Error('error-invalid-webhook-response');
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user