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
Code scanning - action / CodeQL-Build (push) Failing after 1s
Auto-close duplicate issues / auto-close-duplicates (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
Code scanning - action / CodeQL-Build (push) Failing after 1s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
This commit is contained in:
16
apps/meteor/ee/server/lib/abac/index.ts
Normal file
16
apps/meteor/ee/server/lib/abac/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Permissions } from '@rocket.chat/models';
|
||||
|
||||
export const createPermissions = async () => {
|
||||
const permissions = [
|
||||
{ _id: 'abac-management', roles: ['admin'] },
|
||||
{ _id: 'manage-abac-admin-settings', roles: ['admin'] },
|
||||
{ _id: 'manage-abac-admin-room-attributes', roles: ['admin'] },
|
||||
{ _id: 'manage-abac-admin-rooms', roles: ['admin'] },
|
||||
{ _id: 'view-abac-admin-audit', roles: ['admin'] },
|
||||
{ _id: 'bypass-abac-store-validation', roles: [] },
|
||||
];
|
||||
|
||||
for (const permission of permissions) {
|
||||
void Permissions.create(permission._id, permission.roles);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
|
||||
import type { LicenseImp } from '@rocket.chat/license';
|
||||
|
||||
import { i18n } from '../../../../server/lib/i18n';
|
||||
import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins';
|
||||
import { Apps } from '../../apps';
|
||||
|
||||
type OnModuleCallbackParameter = Parameters<Parameters<LicenseImp['onModule']>[0]>[0];
|
||||
|
||||
export async function _disableAppsWithAddonsCallback(
|
||||
deps: { Apps: typeof Apps; sendMessagesToAdmins: typeof sendMessagesToAdmins },
|
||||
{ module, external, valid }: OnModuleCallbackParameter,
|
||||
) {
|
||||
if (!external || valid) return;
|
||||
|
||||
const enabledApps = await deps.Apps.installedApps({ enabled: true });
|
||||
|
||||
if (!enabledApps) return;
|
||||
|
||||
const affectedApps: string[] = [];
|
||||
|
||||
await Promise.all(
|
||||
enabledApps.map(async (app) => {
|
||||
if (app.getInfo().addon !== module) return;
|
||||
|
||||
affectedApps.push(app.getName());
|
||||
|
||||
return deps.Apps.getManager()?.disable(app.getID(), AppStatus.DISABLED, false);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!affectedApps.length) return;
|
||||
|
||||
await deps.sendMessagesToAdmins({
|
||||
msgs: async ({ adminUser }) => ({
|
||||
msg: i18n.t('App_has_been_disabled_addon_message', {
|
||||
lng: adminUser.language || 'en',
|
||||
count: affectedApps.length,
|
||||
appNames: affectedApps,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export const disableAppsWithAddonsCallback = (ctx: OnModuleCallbackParameter) =>
|
||||
_disableAppsWithAddonsCallback({ Apps, sendMessagesToAdmins }, ctx);
|
||||
235
apps/meteor/ee/server/lib/audit/functions.ts
Normal file
235
apps/meteor/ee/server/lib/audit/functions.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { ILivechatAgent, ILivechatVisitor, IMessage, IRoom, IUser, IAuditLog } from '@rocket.chat/core-typings';
|
||||
import { LivechatRooms, Messages, Rooms, Users, AuditLog } from '@rocket.chat/models';
|
||||
import { escapeRegExp, isTruthy } from '@rocket.chat/tools';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
import { hasPermissionAsync } from '../../../../server/lib/authorization/hasPermission';
|
||||
import { callbacks } from '../../../../server/lib/callbacks';
|
||||
import { i18n } from '../../../../server/lib/i18n';
|
||||
import { updateCounter } from '../../../../server/lib/statistics';
|
||||
|
||||
const getValue = (room: IRoom | null) => room && { rids: [room._id], name: room.name };
|
||||
|
||||
const getUsersIdFromUserName = async (usernames: IUser['username'][]) => {
|
||||
const users = usernames ? await Users.findByUsernames(usernames.filter(isTruthy)).toArray() : undefined;
|
||||
|
||||
return users?.filter(isTruthy).map((userId) => userId._id);
|
||||
};
|
||||
|
||||
const getRoomInfoByAuditParams = async ({
|
||||
type,
|
||||
roomId: rid,
|
||||
users: usernames,
|
||||
visitor,
|
||||
agent,
|
||||
userId,
|
||||
}: {
|
||||
type: string;
|
||||
roomId: IRoom['_id'];
|
||||
users: NonNullable<IUser['username']>[];
|
||||
visitor: ILivechatVisitor['_id'];
|
||||
agent: ILivechatAgent['_id'];
|
||||
userId: string;
|
||||
}) => {
|
||||
if (rid) {
|
||||
return getValue(await Rooms.findOne({ _id: rid, abacAttributes: { $exists: false } }));
|
||||
}
|
||||
|
||||
if (type === 'd') {
|
||||
return getValue(await Rooms.findDirectRoomContainingAllUsernames(usernames));
|
||||
}
|
||||
|
||||
if (type === 'l') {
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId });
|
||||
const rooms = await LivechatRooms.findByVisitorIdAndAgentId(
|
||||
visitor,
|
||||
agent,
|
||||
{
|
||||
projection: { _id: 1 },
|
||||
},
|
||||
extraQuery,
|
||||
).toArray();
|
||||
return rooms?.length ? { rids: rooms.map(({ _id }) => _id), name: i18n.t('Omnichannel') } : undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const requireAuditor = async (userId: string | null): Promise<IUser> => {
|
||||
if (!userId) {
|
||||
throw new Meteor.Error('Not allowed');
|
||||
}
|
||||
|
||||
const user = await Users.findOneById(userId);
|
||||
if (!user || !(await hasPermissionAsync(user._id, 'can-audit'))) {
|
||||
throw new Meteor.Error('Not allowed');
|
||||
}
|
||||
return user;
|
||||
};
|
||||
|
||||
type AuditMessagesParams = {
|
||||
rid?: IRoom['_id'];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
users: NonNullable<IUser['username']>[];
|
||||
msg: IMessage['msg'];
|
||||
type: string;
|
||||
visitor?: ILivechatVisitor['_id'];
|
||||
agent?: ILivechatAgent['_id'];
|
||||
};
|
||||
|
||||
export const auditGetMessagesMethod = async (userId: string | null, params: AuditMessagesParams): Promise<IMessage[]> => {
|
||||
const { rid, startDate, endDate, users: usernames, msg, type, visitor, agent } = params;
|
||||
|
||||
const user = await requireAuditor(userId);
|
||||
|
||||
const userFields = {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
...(user.name && { name: user.name }),
|
||||
...(user.avatarETag && { avatarETag: user.avatarETag }),
|
||||
};
|
||||
|
||||
let rids;
|
||||
let name;
|
||||
|
||||
const query: Filter<IMessage> = {
|
||||
ts: {
|
||||
$gt: startDate,
|
||||
$lt: endDate,
|
||||
},
|
||||
};
|
||||
|
||||
if (type === 'u') {
|
||||
const usersId = await getUsersIdFromUserName(usernames);
|
||||
query['u._id'] = { $in: usersId };
|
||||
|
||||
const abacRooms = await Rooms.findAllPrivateRoomsWithAbacAttributes({ projection: { _id: 1 } })
|
||||
.map((doc) => doc._id)
|
||||
.toArray();
|
||||
|
||||
query.rid = { $nin: abacRooms };
|
||||
} else {
|
||||
const roomInfo = await getRoomInfoByAuditParams({
|
||||
type,
|
||||
roomId: rid ?? '',
|
||||
users: usernames,
|
||||
visitor: visitor ?? '',
|
||||
agent: agent ?? '',
|
||||
userId: user._id,
|
||||
});
|
||||
if (!roomInfo) {
|
||||
throw new Meteor.Error(`Room doesn't exist`);
|
||||
}
|
||||
|
||||
rids = roomInfo.rids;
|
||||
name = roomInfo.name;
|
||||
query.rid = { $in: rids };
|
||||
}
|
||||
|
||||
if (msg) {
|
||||
const regex = new RegExp(escapeRegExp(msg).trim(), 'i');
|
||||
query.msg = regex;
|
||||
}
|
||||
|
||||
const messages = await Messages.find(query).toArray();
|
||||
|
||||
await AuditLog.insertOne(
|
||||
{
|
||||
ts: new Date(),
|
||||
results: messages.length,
|
||||
u: userFields,
|
||||
fields: { msg, users: usernames, rids, room: name, startDate, endDate, type, visitor, agent },
|
||||
},
|
||||
{ ignoreUndefined: true },
|
||||
);
|
||||
|
||||
updateCounter({ settingsId: 'Message_Auditing_Panel_Load_Count' });
|
||||
|
||||
return messages;
|
||||
};
|
||||
|
||||
type AuditOmnichannelMessagesParams = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
users: NonNullable<IUser['username']>[];
|
||||
msg: IMessage['msg'];
|
||||
type: string;
|
||||
visitor?: ILivechatVisitor['_id'];
|
||||
agent?: ILivechatAgent['_id'];
|
||||
};
|
||||
|
||||
export const auditGetOmnichannelMessagesMethod = async (
|
||||
userId: string | null,
|
||||
params: AuditOmnichannelMessagesParams,
|
||||
): Promise<IMessage[]> => {
|
||||
const { startDate, endDate, users: usernames, msg, type, visitor, agent } = params;
|
||||
|
||||
const user = await requireAuditor(userId);
|
||||
|
||||
const userFields = {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
...(user.name && { name: user.name }),
|
||||
...(user.avatarETag && { avatarETag: user.avatarETag }),
|
||||
};
|
||||
|
||||
// No livechat.applyRoomRestrictions here (unlike the type 'l' path in getRoomInfoByAuditParams): this
|
||||
// mirrors the original DDP auditGetOmnichannelMessages. Access is gated by the can-audit permission and
|
||||
// audit is intended to span all omnichannel rooms, so unit/visibility restrictions are not applied.
|
||||
const rooms = await LivechatRooms.findByVisitorIdAndAgentId(visitor, agent, {
|
||||
projection: { _id: 1 },
|
||||
}).toArray();
|
||||
// keep rids an array — `$in: undefined` throws "$in needs an array" when no rooms match
|
||||
const rids = rooms.map(({ _id }) => _id);
|
||||
const name = i18n.t('Omnichannel');
|
||||
|
||||
const query: Filter<IMessage> = {
|
||||
rid: { $in: rids },
|
||||
ts: {
|
||||
$gt: startDate,
|
||||
$lt: endDate,
|
||||
},
|
||||
};
|
||||
|
||||
if (msg) {
|
||||
const regex = new RegExp(escapeRegExp(msg).trim(), 'i');
|
||||
query.msg = regex;
|
||||
}
|
||||
|
||||
const messages = await Messages.find(query).toArray();
|
||||
|
||||
await AuditLog.insertOne(
|
||||
{
|
||||
ts: new Date(),
|
||||
results: messages.length,
|
||||
u: userFields,
|
||||
fields: { msg, users: usernames, rids, room: name, startDate, endDate, type, visitor, agent },
|
||||
},
|
||||
{ ignoreUndefined: true },
|
||||
);
|
||||
|
||||
return messages;
|
||||
};
|
||||
|
||||
export const auditGetAuditionsMethod = async (userId: string | null, startDate: Date, endDate: Date): Promise<IAuditLog[]> => {
|
||||
if (!userId || !(await hasPermissionAsync(userId, 'can-audit-log'))) {
|
||||
throw new Meteor.Error('Not allowed');
|
||||
}
|
||||
return AuditLog.find(
|
||||
{
|
||||
ts: {
|
||||
$gt: startDate,
|
||||
$lt: endDate,
|
||||
},
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
'u.services': 0,
|
||||
'u.roles': 0,
|
||||
'u.lastLogin': 0,
|
||||
'u.statusConnection': 0,
|
||||
'u.emails': 0,
|
||||
},
|
||||
},
|
||||
).toArray();
|
||||
};
|
||||
79
apps/meteor/ee/server/lib/audit/methods.ts
Normal file
79
apps/meteor/ee/server/lib/audit/methods.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { ILivechatAgent, ILivechatVisitor, IMessage, IRoom, IUser, IAuditLog } from '@rocket.chat/core-typings';
|
||||
import type { ServerMethods } from '@rocket.chat/ddp-client';
|
||||
import { check } from 'meteor/check';
|
||||
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { auditGetAuditionsMethod, auditGetMessagesMethod, auditGetOmnichannelMessagesMethod } from './functions';
|
||||
import { methodDeprecationLogger } from '../../../../server/lib/deprecationWarningLogger';
|
||||
|
||||
declare module '@rocket.chat/ddp-client' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface ServerMethods {
|
||||
auditGetAuditions: (params: { startDate: Date; endDate: Date }) => IAuditLog[];
|
||||
auditGetMessages: (params: {
|
||||
rid: IRoom['_id'];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
users: NonNullable<IUser['username']>[];
|
||||
msg: IMessage['msg'];
|
||||
type: string;
|
||||
visitor: ILivechatVisitor['_id'];
|
||||
agent: ILivechatAgent['_id'];
|
||||
}) => IMessage[];
|
||||
auditGetOmnichannelMessages: (params: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
users: NonNullable<IUser['username']>[];
|
||||
msg: IMessage['msg'];
|
||||
type: string;
|
||||
visitor?: ILivechatVisitor['_id'];
|
||||
agent?: ILivechatAgent['_id'];
|
||||
}) => IMessage[];
|
||||
}
|
||||
}
|
||||
|
||||
Meteor.methods<ServerMethods>({
|
||||
async auditGetOmnichannelMessages(params) {
|
||||
methodDeprecationLogger.method('auditGetOmnichannelMessages', '9.0.0', '/v1/audit.omnichannelMessages');
|
||||
check(params.startDate, Date);
|
||||
check(params.endDate, Date);
|
||||
return auditGetOmnichannelMessagesMethod(Meteor.userId(), params);
|
||||
},
|
||||
async auditGetMessages(params) {
|
||||
methodDeprecationLogger.method('auditGetMessages', '9.0.0', '/v1/audit.messages');
|
||||
check(params.startDate, Date);
|
||||
check(params.endDate, Date);
|
||||
return auditGetMessagesMethod(Meteor.userId(), params);
|
||||
},
|
||||
async auditGetAuditions({ startDate, endDate }) {
|
||||
methodDeprecationLogger.method('auditGetAuditions', '9.0.0', '/v1/audit.auditions');
|
||||
check(startDate, Date);
|
||||
check(endDate, Date);
|
||||
return auditGetAuditionsMethod(Meteor.userId(), startDate, endDate);
|
||||
},
|
||||
});
|
||||
|
||||
DDPRateLimiter.addRule(
|
||||
{
|
||||
type: 'method',
|
||||
name: 'auditGetAuditions',
|
||||
userId(/* userId*/) {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
10,
|
||||
60000,
|
||||
);
|
||||
|
||||
DDPRateLimiter.addRule(
|
||||
{
|
||||
type: 'method',
|
||||
name: 'auditGetMessages',
|
||||
userId(/* userId*/) {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
10,
|
||||
60000,
|
||||
);
|
||||
22
apps/meteor/ee/server/lib/audit/startup.ts
Normal file
22
apps/meteor/ee/server/lib/audit/startup.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Permissions } from '@rocket.chat/models';
|
||||
|
||||
import { createOrUpdateProtectedRoleAsync } from '../../../../server/lib/roles/createOrUpdateProtectedRole';
|
||||
|
||||
export const createPermissions = async () => {
|
||||
const permissions = [
|
||||
{ _id: 'can-audit', roles: ['admin', 'auditor'] },
|
||||
{ _id: 'can-audit-log', roles: ['admin', 'auditor-log'] },
|
||||
{ _id: 'view-members-list-all-rooms', roles: ['admin', 'auditor'] },
|
||||
];
|
||||
|
||||
const defaultRoles = [
|
||||
{ name: 'auditor', scope: 'Users' },
|
||||
{ name: 'auditor-log', scope: 'Users' },
|
||||
] as const;
|
||||
|
||||
for (const permission of permissions) {
|
||||
void Permissions.create(permission._id, permission.roles);
|
||||
}
|
||||
|
||||
return Promise.all(defaultRoles.map((role) => createOrUpdateProtectedRoleAsync(role.name, role)));
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// This list is currently duplicated on the client code as there's no available API to load it from the server
|
||||
export const guestPermissions = ['view-d-room', 'view-joined-room', 'view-p-room', 'start-discussion', 'mobile-upload-file'];
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Permissions } from '@rocket.chat/models';
|
||||
|
||||
import { guestPermissions } from './guestPermissions';
|
||||
|
||||
export const resetEnterprisePermissions = async function (): Promise<void> {
|
||||
await Permissions.updateMany(
|
||||
{
|
||||
_id: { $nin: guestPermissions },
|
||||
roles: 'guest',
|
||||
},
|
||||
{ $pull: { roles: 'guest' } },
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import { License, MockedLicenseBuilder } from '@rocket.chat/license';
|
||||
|
||||
import { validateUserRoles } from './validateUserRoles';
|
||||
|
||||
beforeEach(async () => {
|
||||
const license = new MockedLicenseBuilder();
|
||||
await License.setWorkspaceUrl('http://localhost:3000');
|
||||
License.setLicenseLimitCounter('activeUsers', () => 0);
|
||||
License.setLicenseLimitCounter('guestUsers', () => 0);
|
||||
License.setLicenseLimitCounter('roomsPerGuest', async () => 0);
|
||||
License.setLicenseLimitCounter('privateApps', () => 0);
|
||||
License.setLicenseLimitCounter('marketplaceApps', () => 0);
|
||||
License.setLicenseLimitCounter('monthlyActiveContacts', async () => 0);
|
||||
|
||||
License.setLicenseLimitCounter('activeUsers', () => 1);
|
||||
|
||||
license
|
||||
.withLimits('activeUsers', [
|
||||
{
|
||||
max: 1,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
])
|
||||
.withLimits('guestUsers', [
|
||||
{
|
||||
max: 1,
|
||||
behavior: 'prevent_action',
|
||||
},
|
||||
]);
|
||||
|
||||
await License.setLicense(await license.sign());
|
||||
});
|
||||
|
||||
describe('Operating after activeUsers Limits', () => {
|
||||
beforeEach(async () => {
|
||||
License.setLicenseLimitCounter('activeUsers', () => 1);
|
||||
});
|
||||
|
||||
describe('Adding a new user', () => {
|
||||
it('should throw error when user is active as undefined', async () => {
|
||||
const user = {
|
||||
roles: ['user'],
|
||||
};
|
||||
|
||||
await expect(validateUserRoles(user)).rejects.toThrow(MeteorError);
|
||||
});
|
||||
|
||||
it('should not throw error when user is not active', async () => {
|
||||
const user = {
|
||||
active: false,
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
await expect(validateUserRoles(user)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when user is an app', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'app',
|
||||
};
|
||||
|
||||
await expect(validateUserRoles(user)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when user is a bot', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'bot',
|
||||
};
|
||||
|
||||
await expect(validateUserRoles(user)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when user is a guest', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
roles: ['guest'],
|
||||
};
|
||||
|
||||
await expect(validateUserRoles(user)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when user is active', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
await expect(validateUserRoles(user)).rejects.toThrow(MeteorError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editing an existing user', () => {
|
||||
beforeEach(async () => {
|
||||
License.setLicenseLimitCounter('guestUsers', () => 1);
|
||||
});
|
||||
afterEach(async () => {
|
||||
License.setLicenseLimitCounter('guestUsers', () => 0);
|
||||
});
|
||||
|
||||
it('should throw an error when we try to activate a user', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
const currentUser = {
|
||||
active: false,
|
||||
type: 'user',
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).rejects.toThrow(MeteorError);
|
||||
});
|
||||
|
||||
it('should not throw an error when we try to deactivate a user', async () => {
|
||||
const user = {
|
||||
active: false,
|
||||
type: 'user',
|
||||
};
|
||||
const currentUser = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw an error when we try to change an active user', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
const currentUser = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw an error when we try to convert a bot to a user', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
const currentUser = {
|
||||
active: true,
|
||||
type: 'bot',
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).rejects.toThrow(MeteorError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Operating after guestUsers Limits', () => {
|
||||
beforeEach(async () => {
|
||||
License.setLicenseLimitCounter('guestUsers', () => 1);
|
||||
});
|
||||
|
||||
it('should throw an error when we try to convert an user to guest', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
roles: ['guest'],
|
||||
};
|
||||
const currentUser = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).rejects.toThrow(MeteorError);
|
||||
});
|
||||
|
||||
it('should throw an error when we try to convert app to guest', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
roles: ['guest'],
|
||||
};
|
||||
const currentUser = {
|
||||
active: true,
|
||||
type: 'app',
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).rejects.toThrow(MeteorError);
|
||||
});
|
||||
|
||||
it('should not throw an error when we try to edit a guest', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
roles: ['guest'],
|
||||
};
|
||||
const currentUser = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
roles: ['guest'],
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Operating under activeUsers Limits', () => {
|
||||
beforeEach(async () => {
|
||||
License.setLicenseLimitCounter('activeUsers', () => 0);
|
||||
});
|
||||
|
||||
describe('Adding a new user', () => {
|
||||
it('should not throw an error validating a regular user', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
await expect(validateUserRoles(user)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editing an existing user', () => {
|
||||
it('should not throw an error when we try to activate a user', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
const currentUser = {
|
||||
active: false,
|
||||
type: 'user',
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw an error when we try to convert a guest to a user', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
const currentUser = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
roles: ['guest'],
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
|
||||
});
|
||||
it('should not throw an error when we try to convert a bot to a user', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
const currentUser = {
|
||||
active: true,
|
||||
type: 'bot',
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
|
||||
});
|
||||
it('should not throw an error when we try to convert an app to a user', async () => {
|
||||
const user = {
|
||||
active: true,
|
||||
type: 'user',
|
||||
};
|
||||
const currentUser = {
|
||||
active: true,
|
||||
type: 'app',
|
||||
};
|
||||
await expect(validateUserRoles(user, currentUser)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
52
apps/meteor/ee/server/lib/authorization/validateUserRoles.ts
Normal file
52
apps/meteor/ee/server/lib/authorization/validateUserRoles.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
|
||||
import { i18n } from '../../../../server/lib/i18n';
|
||||
|
||||
export async function validateUserRoles(userData: Partial<IUser>, currentUserData?: Partial<IUser>) {
|
||||
const isApp = Boolean(userData.type === 'app');
|
||||
const wasApp = Boolean(currentUserData?.type === 'app');
|
||||
|
||||
const isBot = Boolean(userData.type === 'bot');
|
||||
const wasBot = Boolean(currentUserData?.type === 'bot');
|
||||
|
||||
const isGuest = Boolean(userData.roles?.includes('guest') && userData.roles.length === 1);
|
||||
const wasGuest = Boolean(currentUserData?.roles?.includes('guest') && currentUserData.roles.length === 1);
|
||||
|
||||
const isSpecialType = isApp || isBot;
|
||||
|
||||
const hasGuestToChanged = isGuest && !wasGuest;
|
||||
|
||||
if (isSpecialType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasGuestToChanged && (await License.shouldPreventAction('guestUsers'))) {
|
||||
throw new MeteorError('error-max-guests-number-reached', 'Maximum number of guests reached.', {
|
||||
method: 'insertOrUpdateUser',
|
||||
field: 'Assign_role',
|
||||
});
|
||||
}
|
||||
|
||||
if (isGuest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = Boolean(userData.active !== false);
|
||||
const wasActive = currentUserData && currentUserData?.active !== false;
|
||||
|
||||
const hasRemovedSpecialType = (wasApp && !isApp) || (wasBot && !isBot);
|
||||
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasRemovedSpecialType && wasActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await License.shouldPreventAction('activeUsers')) {
|
||||
throw new MeteorError('error-license-user-limit-reached', i18n.t('error-license-user-limit-reached'));
|
||||
}
|
||||
}
|
||||
13
apps/meteor/ee/server/lib/canned-responses/index.ts
Normal file
13
apps/meteor/ee/server/lib/canned-responses/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { License } from '@rocket.chat/license';
|
||||
|
||||
await License.onLicense('canned-responses', async () => {
|
||||
const { createSettings } = await import('./settings');
|
||||
await import('./permissions');
|
||||
await import('../../hooks/canned-responses/onRemoveAgentDepartment');
|
||||
await import('../../hooks/canned-responses/onSaveAgentDepartment');
|
||||
await import('../../hooks/canned-responses/cannedResponses');
|
||||
await import('../../meteor-methods/saveCannedResponse');
|
||||
await import('../../meteor-methods/removeCannedResponse');
|
||||
|
||||
await createSettings();
|
||||
});
|
||||
12
apps/meteor/ee/server/lib/canned-responses/permissions.ts
Normal file
12
apps/meteor/ee/server/lib/canned-responses/permissions.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Permissions } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
Meteor.startup(async () => {
|
||||
await Permissions.create('view-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']);
|
||||
await Permissions.create('view-all-canned-responses', ['livechat-manager', 'admin']);
|
||||
await Permissions.create('view-agent-canned-responses', ['livechat-agent']);
|
||||
await Permissions.create('save-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']);
|
||||
await Permissions.create('save-all-canned-responses', ['livechat-manager', 'admin']);
|
||||
await Permissions.create('save-department-canned-responses', ['livechat-monitor']);
|
||||
await Permissions.create('remove-canned-responses', ['livechat-agent', 'livechat-monitor', 'livechat-manager', 'admin']);
|
||||
});
|
||||
16
apps/meteor/ee/server/lib/canned-responses/settings.ts
Normal file
16
apps/meteor/ee/server/lib/canned-responses/settings.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { settingsRegistry } from '../../../../server/settings';
|
||||
|
||||
const omnichannelEnabledQuery = { _id: 'Livechat_enabled', value: true };
|
||||
|
||||
export const createSettings = async (): Promise<void> => {
|
||||
await settingsRegistry.add('Canned_Responses_Enable', true, {
|
||||
group: 'Omnichannel',
|
||||
section: 'Canned_Responses',
|
||||
type: 'boolean',
|
||||
public: true,
|
||||
enterprise: true,
|
||||
invalidValue: false,
|
||||
modules: ['canned-responses'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
};
|
||||
1
apps/meteor/ee/server/lib/constants.ts
Normal file
1
apps/meteor/ee/server/lib/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MAX_ROOM_SIZE_CHECK_INDIVIDUAL_READ_RECEIPTS = 50;
|
||||
119
apps/meteor/ee/server/lib/deviceManagement/session.ts
Normal file
119
apps/meteor/ee/server/lib/deviceManagement/session.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { ISocketConnection } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import moment from 'moment';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { t } from '../../../../app/utils/lib/i18n';
|
||||
import * as Mailer from '../../../../server/lib/notifications/email/api';
|
||||
import { UAParserDesktop, UAParserMobile } from '../../../../server/lib/statistics/lib/UAParserCustom';
|
||||
import { getUserPreference } from '../../../../server/lib/utils/lib/getUserPreference';
|
||||
import { deviceManagementEvents } from '../../../../server/services/device-management/events';
|
||||
import { settings } from '../../../../server/settings';
|
||||
|
||||
let mailTemplates: string;
|
||||
|
||||
Meteor.startup(() => {
|
||||
Mailer.getTemplate('Device_Management_Email_Body', (template) => {
|
||||
mailTemplates = template;
|
||||
});
|
||||
});
|
||||
|
||||
const uaParser = async (
|
||||
uaString: ISocketConnection['httpHeaders']['user-agent'],
|
||||
): Promise<UAParser.IResult & { app?: { name: string; version: string; bundle: string } }> => {
|
||||
const ua = new UAParser(uaString);
|
||||
return {
|
||||
...ua.getResult(),
|
||||
...(uaString && UAParserMobile.isMobileApp(uaString) && UAParserMobile.uaObject(uaString)),
|
||||
...(uaString && UAParserDesktop.isDesktopApp(uaString) && UAParserDesktop.uaObject(uaString)),
|
||||
};
|
||||
};
|
||||
|
||||
export const listenSessionLogin = () => {
|
||||
return deviceManagementEvents.on('device-login', async ({ userId, userAgent, clientAddress }) => {
|
||||
const deviceEnabled = settings.get('Device_Management_Enable_Login_Emails');
|
||||
|
||||
if (!deviceEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await Users.findOneByIdWithEmailAddress(userId, {
|
||||
projection: { 'name': 1, 'username': 1, 'emails': 1, 'settings.preferences.receiveLoginDetectionEmail': 1 },
|
||||
});
|
||||
|
||||
if (!user?.emails?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userReceiveLoginEmailPreference =
|
||||
!settings.get('Device_Management_Allow_Login_Email_preference') ||
|
||||
(await getUserPreference(userId, 'receiveLoginDetectionEmail', true));
|
||||
|
||||
if (!userReceiveLoginEmailPreference) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateFormat = settings.get('Message_TimeAndDateFormat');
|
||||
|
||||
const {
|
||||
name,
|
||||
username,
|
||||
emails: [{ address: email }],
|
||||
} = user;
|
||||
|
||||
const { browser, os, device, cpu, app } = await uaParser(userAgent);
|
||||
|
||||
const mailData = {
|
||||
name,
|
||||
username,
|
||||
browserInfo: `${browser.name} ${browser.version}`,
|
||||
osInfo: `${os.name}`,
|
||||
deviceInfo: `${device.type || t('Device_Management_Device_Unknown')} ${device.vendor || ''} ${device.model || ''} ${
|
||||
cpu.architecture || ''
|
||||
}`,
|
||||
ipInfo: clientAddress,
|
||||
userAgent: '',
|
||||
date: moment().format(String(dateFormat)),
|
||||
};
|
||||
|
||||
switch (device.type) {
|
||||
case 'mobile':
|
||||
case 'tablet':
|
||||
case 'smarttv':
|
||||
mailData.browserInfo = `${browser.name} ${browser.version}`;
|
||||
mailData.osInfo = `${os.name}`;
|
||||
mailData.deviceInfo = `${device.type} ${device.vendor || ''} ${device.model || ''} ${cpu.architecture || ''}`;
|
||||
break;
|
||||
case 'mobile-app':
|
||||
mailData.browserInfo = `Rocket.Chat App ${app?.bundle || app?.version}`;
|
||||
mailData.osInfo = `${os.name}`;
|
||||
mailData.deviceInfo = 'Mobile App';
|
||||
break;
|
||||
case 'desktop-app':
|
||||
mailData.browserInfo = `Rocket.Chat ${app?.name || browser.name} ${app?.bundle || app?.version || browser.version}`;
|
||||
mailData.osInfo = `${os.name}`;
|
||||
mailData.deviceInfo = `Desktop App ${cpu.architecture || ''}`;
|
||||
break;
|
||||
default:
|
||||
mailData.userAgent = userAgent || '';
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await Mailer.send({
|
||||
to: `${name} <${email}>`,
|
||||
from: Accounts.emailTemplates.from,
|
||||
subject: settings.get('Device_Management_Email_Subject'),
|
||||
html: mailTemplates,
|
||||
data: mailData,
|
||||
});
|
||||
} catch ({ message }: any) {
|
||||
throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${message}`, {
|
||||
method: 'listenSessionLogin',
|
||||
message,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
32
apps/meteor/ee/server/lib/deviceManagement/startup.ts
Normal file
32
apps/meteor/ee/server/lib/deviceManagement/startup.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Permissions } from '@rocket.chat/models';
|
||||
|
||||
import { settingsRegistry } from '../../../../server/settings';
|
||||
|
||||
export const createPermissions = async (): Promise<void> => {
|
||||
await Promise.all([
|
||||
Permissions.create('logout-device-management', ['admin']),
|
||||
Permissions.create('block-ip-device-management', ['admin']),
|
||||
]);
|
||||
};
|
||||
|
||||
export const createEmailTemplates = async (): Promise<void> => {
|
||||
await settingsRegistry.addGroup('Email', async function () {
|
||||
await this.section('Device Management - Login Detected', async function () {
|
||||
await this.add('Device_Management_Email_Subject', '{Device_Management_Email_Subject}', {
|
||||
type: 'string',
|
||||
i18nLabel: 'Subject',
|
||||
});
|
||||
await this.add(
|
||||
'Device_Management_Email_Body',
|
||||
'<h2 class="rc-color">{Login_Detected}</h2><p><strong>[name] ([username]) {Logged_In_Via}</strong></p><p><strong>{Device_Management_Client}:</strong> [browserInfo]<br><strong>{Device_Management_OS}:</strong> [osInfo]<br><strong>{Device_Management_Device}:</strong> [deviceInfo]<br><strong>{Device_Management_IP}:</strong>[ipInfo]<br><strong>{Date}:</strong> [date]</p><p><small>[userAgent]</small></p><a class="btn" href="[Site_URL]">{Access_Your_Account}</a><p>{Or_Copy_And_Paste_This_URL_Into_A_Tab_Of_Your_Browser}<br><a href="[Site_URL]">[SITE_URL]</a></p><p>{Thank_You_For_Choosing_RocketChat}</p>',
|
||||
{
|
||||
type: 'code',
|
||||
code: 'text/html',
|
||||
multiline: true,
|
||||
i18nLabel: 'Body',
|
||||
i18nDescription: 'Device_Management_Email_Body',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
59
apps/meteor/ee/server/lib/engagementDashboard/channels.ts
Normal file
59
apps/meteor/ee/server/lib/engagementDashboard/channels.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { IDirectMessageRoom, IRoom } from '@rocket.chat/core-typings';
|
||||
import { Analytics } from '@rocket.chat/models';
|
||||
import moment from 'moment';
|
||||
|
||||
import { convertDateToInt, diffBetweenDaysInclusive } from './date';
|
||||
import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator';
|
||||
|
||||
export const findChannelsWithNumberOfMessages = async ({
|
||||
start,
|
||||
end,
|
||||
options = {},
|
||||
}: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
options: {
|
||||
offset?: number;
|
||||
count?: number;
|
||||
};
|
||||
}): Promise<{
|
||||
channels: {
|
||||
room: {
|
||||
_id: IRoom['_id'];
|
||||
name: IRoom['name'] | IRoom['fname'];
|
||||
ts: IRoom['ts'];
|
||||
t: IRoom['t'];
|
||||
_updatedAt: IRoom['_updatedAt'];
|
||||
usernames?: IDirectMessageRoom['usernames'];
|
||||
};
|
||||
messages: number;
|
||||
lastWeekMessages: number;
|
||||
diffFromLastWeek: number;
|
||||
}[];
|
||||
total: number;
|
||||
}> => {
|
||||
const daysBetweenDates = diffBetweenDaysInclusive(end, start);
|
||||
const endOfLastWeek = moment(start).subtract(1, 'days').toDate();
|
||||
const startOfLastWeek = moment(endOfLastWeek).subtract(daysBetweenDates, 'days').toDate();
|
||||
const roomTypes = roomCoordinator.getTypesToShowOnDashboard() as Array<IRoom['t']>;
|
||||
|
||||
const aggregationResult = await Analytics.findRoomsByTypesWithNumberOfMessagesBetweenDate({
|
||||
types: roomTypes,
|
||||
start: convertDateToInt(start),
|
||||
end: convertDateToInt(end),
|
||||
startOfLastWeek: convertDateToInt(startOfLastWeek),
|
||||
endOfLastWeek: convertDateToInt(endOfLastWeek),
|
||||
options,
|
||||
}).toArray();
|
||||
|
||||
// The aggregation result may be undefined if there are no matching analytics or corresponding rooms in the period
|
||||
if (!aggregationResult.length) {
|
||||
return { channels: [], total: 0 };
|
||||
}
|
||||
|
||||
const [{ channels, total }] = aggregationResult;
|
||||
return {
|
||||
channels,
|
||||
total,
|
||||
};
|
||||
};
|
||||
40
apps/meteor/ee/server/lib/engagementDashboard/date.ts
Normal file
40
apps/meteor/ee/server/lib/engagementDashboard/date.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import mem from 'mem';
|
||||
import moment from 'moment';
|
||||
|
||||
export const isDateISOString = mem(
|
||||
(input: string): input is string => {
|
||||
const timestamp = Date.parse(input);
|
||||
return !Number.isNaN(timestamp) && new Date(timestamp).toISOString() === input;
|
||||
},
|
||||
{ maxAge: 10000 },
|
||||
);
|
||||
|
||||
export const mapDateForAPI = (input: string): Date => {
|
||||
if (!isDateISOString(input)) {
|
||||
throw new Error('invalid ISO 8601 date');
|
||||
}
|
||||
|
||||
return new Date(Date.parse(input));
|
||||
};
|
||||
|
||||
export const convertDateToInt = (date: Date): number => parseInt(moment(date).clone().format('YYYYMMDD'), 10);
|
||||
export const convertIntToDate = (intValue: number): Date => moment(intValue, 'YYYYMMDD').clone().toDate();
|
||||
const diffBetweenDays = (start: string | number | Date, end: string | number | Date): number =>
|
||||
moment(new Date(start)).clone().diff(new Date(end), 'days');
|
||||
export const diffBetweenDaysInclusive = (start: string | number | Date, end: string | number | Date): number =>
|
||||
diffBetweenDays(start, end) + 1;
|
||||
|
||||
export const getTotalOfWeekItems = <T extends Record<string, number>>(weekItems: T[], property: keyof T): number =>
|
||||
weekItems.reduce((acc, item) => {
|
||||
acc += item[property];
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
export function transformDatesForAPI(start: string): { start: Date; end: undefined };
|
||||
export function transformDatesForAPI(start: string, end: string): { start: Date; end: Date };
|
||||
export function transformDatesForAPI(start: string, end?: string): { start: Date; end: Date | undefined } {
|
||||
return {
|
||||
start: mapDateForAPI(start),
|
||||
end: end ? mapDateForAPI(end) : undefined,
|
||||
};
|
||||
}
|
||||
156
apps/meteor/ee/server/lib/engagementDashboard/messages.ts
Normal file
156
apps/meteor/ee/server/lib/engagementDashboard/messages.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { IDirectMessageRoom, IRoom, IMessage } from '@rocket.chat/core-typings';
|
||||
import { Messages, Analytics } from '@rocket.chat/models';
|
||||
import moment from 'moment';
|
||||
|
||||
import { convertDateToInt, diffBetweenDaysInclusive, convertIntToDate, getTotalOfWeekItems } from './date';
|
||||
import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator';
|
||||
|
||||
export const handleMessagesSent = async (message: IMessage, { room }: { room?: IRoom }): Promise<IMessage> => {
|
||||
const roomTypesToShow = roomCoordinator.getTypesToShowOnDashboard();
|
||||
if (!room || !roomTypesToShow.includes(room.t)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
await Analytics.saveMessageSent({
|
||||
date: convertDateToInt(message.ts),
|
||||
room,
|
||||
});
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
export const handleMessagesDeleted = async (message: IMessage, { room }: { room: IRoom }): Promise<IMessage> => {
|
||||
const roomTypesToShow = roomCoordinator.getTypesToShowOnDashboard();
|
||||
if (!room || !roomTypesToShow.includes(room.t)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
await Analytics.saveMessageDeleted({
|
||||
date: convertDateToInt(message.ts),
|
||||
room,
|
||||
});
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
export const fillFirstDaysOfMessagesIfNeeded = async (date: Date): Promise<void> => {
|
||||
const messagesFromAnalytics = await Analytics.findOneByTypeBeforeDate({
|
||||
type: 'messages',
|
||||
date: convertDateToInt(date),
|
||||
});
|
||||
if (!messagesFromAnalytics) {
|
||||
const startOfPeriod = moment(date).subtract(90, 'days').toDate();
|
||||
const messages = await Messages.getTotalOfMessagesSentByDate({
|
||||
start: startOfPeriod,
|
||||
end: date,
|
||||
});
|
||||
await Promise.all(
|
||||
messages.map((message) =>
|
||||
Analytics.insertOne({
|
||||
...message,
|
||||
date: parseInt(message.date),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const findWeeklyMessagesSentData = async ({
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}): Promise<{
|
||||
days: { day: Date; messages: number }[];
|
||||
period: {
|
||||
count: number;
|
||||
variation: number;
|
||||
};
|
||||
yesterday: {
|
||||
count: number;
|
||||
variation: number;
|
||||
};
|
||||
}> => {
|
||||
const daysBetweenDates = diffBetweenDaysInclusive(end, start);
|
||||
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate();
|
||||
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate();
|
||||
const today = convertDateToInt(end);
|
||||
const yesterday = convertDateToInt(moment(end).clone().subtract(1, 'days').toDate());
|
||||
const currentPeriodMessages = await Analytics.getMessagesSentTotalByDate({
|
||||
start: convertDateToInt(start),
|
||||
end: convertDateToInt(end),
|
||||
options: { count: daysBetweenDates, sort: { _id: -1 } },
|
||||
}).toArray();
|
||||
const lastPeriodMessages = await Analytics.getMessagesSentTotalByDate({
|
||||
start: convertDateToInt(startOfLastWeek),
|
||||
end: convertDateToInt(endOfLastWeek),
|
||||
options: { count: daysBetweenDates, sort: { _id: -1 } },
|
||||
}).toArray();
|
||||
const yesterdayMessages = currentPeriodMessages.find((item) => item._id === yesterday)?.messages || 0;
|
||||
const todayMessages = currentPeriodMessages.find((item) => item._id === today)?.messages || 0;
|
||||
const currentPeriodTotalOfMessages = getTotalOfWeekItems(currentPeriodMessages, 'messages');
|
||||
const lastPeriodTotalOfMessages = getTotalOfWeekItems(lastPeriodMessages, 'messages');
|
||||
return {
|
||||
days: currentPeriodMessages.map((day) => ({
|
||||
day: convertIntToDate(day._id),
|
||||
messages: day.messages,
|
||||
})),
|
||||
period: {
|
||||
count: currentPeriodTotalOfMessages,
|
||||
variation: currentPeriodTotalOfMessages - lastPeriodTotalOfMessages,
|
||||
},
|
||||
yesterday: {
|
||||
count: yesterdayMessages,
|
||||
variation: todayMessages - yesterdayMessages,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const findMessagesSentOrigin = async ({
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}): Promise<{
|
||||
origins: {
|
||||
t: IRoom['t'];
|
||||
messages: number;
|
||||
}[];
|
||||
}> => {
|
||||
const origins = await Analytics.getMessagesOrigin({
|
||||
start: convertDateToInt(start),
|
||||
end: convertDateToInt(end),
|
||||
}).toArray();
|
||||
const roomTypesToShow: IRoom['t'][] = roomCoordinator.getTypesToShowOnDashboard() as IRoom['t'][];
|
||||
const responseTypes = origins.map((origin) => origin.t);
|
||||
const missingTypes = roomTypesToShow.filter((type): type is IRoom['t'] => !responseTypes.includes(type));
|
||||
if (missingTypes.length) {
|
||||
missingTypes.forEach((type) => origins.push({ messages: 0, t: type }));
|
||||
}
|
||||
|
||||
return { origins };
|
||||
};
|
||||
|
||||
export const findTopFivePopularChannelsByMessageSentQuantity = async ({
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}): Promise<{
|
||||
channels: {
|
||||
t: IRoom['t'];
|
||||
messages: number;
|
||||
name: IRoom['name'] | IRoom['fname'];
|
||||
usernames?: IDirectMessageRoom['usernames'];
|
||||
}[];
|
||||
}> => {
|
||||
const channels = await Analytics.getMostPopularChannelsByMessagesSentQuantity({
|
||||
start: convertDateToInt(start),
|
||||
end: convertDateToInt(end),
|
||||
options: { count: 5, sort: { messages: -1 } },
|
||||
}).toArray();
|
||||
return { channels };
|
||||
};
|
||||
25
apps/meteor/ee/server/lib/engagementDashboard/startup.ts
Normal file
25
apps/meteor/ee/server/lib/engagementDashboard/startup.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { fillFirstDaysOfMessagesIfNeeded, handleMessagesDeleted, handleMessagesSent } from './messages';
|
||||
import { fillFirstDaysOfUsersIfNeeded, handleUserCreated } from './users';
|
||||
import { callbacks } from '../../../../server/lib/callbacks';
|
||||
|
||||
export const attachCallbacks = (): void => {
|
||||
callbacks.add(
|
||||
'afterSaveMessage',
|
||||
(message, { room }) => handleMessagesSent(message, { room }),
|
||||
callbacks.priority.MEDIUM,
|
||||
'engagementDashboard.afterSaveMessage',
|
||||
);
|
||||
callbacks.add('afterDeleteMessage', handleMessagesDeleted, callbacks.priority.MEDIUM, 'engagementDashboard.afterDeleteMessage');
|
||||
callbacks.add('afterCreateUser', handleUserCreated, callbacks.priority.MEDIUM, 'engagementDashboard.afterCreateUser');
|
||||
};
|
||||
|
||||
export const detachCallbacks = (): void => {
|
||||
callbacks.remove('afterSaveMessage', 'engagementDashboard.afterSaveMessage');
|
||||
callbacks.remove('afterDeleteMessage', 'engagementDashboard.afterDeleteMessage');
|
||||
callbacks.remove('afterCreateUser', 'engagementDashboard.afterCreateUser');
|
||||
};
|
||||
|
||||
export const prepareAnalytics = async (): Promise<void> => {
|
||||
const now = new Date();
|
||||
await Promise.all([fillFirstDaysOfUsersIfNeeded(now), fillFirstDaysOfMessagesIfNeeded(now)]);
|
||||
};
|
||||
179
apps/meteor/ee/server/lib/engagementDashboard/users.ts
Normal file
179
apps/meteor/ee/server/lib/engagementDashboard/users.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Users, Analytics, Sessions } from '@rocket.chat/models';
|
||||
import moment from 'moment';
|
||||
|
||||
import { convertDateToInt, diffBetweenDaysInclusive, getTotalOfWeekItems, convertIntToDate } from './date';
|
||||
|
||||
export const handleUserCreated = async (user: IUser): Promise<IUser> => {
|
||||
if (user.roles?.includes('anonymous')) {
|
||||
return user;
|
||||
}
|
||||
|
||||
await Analytics.saveUserData({
|
||||
date: convertDateToInt(user.createdAt),
|
||||
});
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const fillFirstDaysOfUsersIfNeeded = async (date: Date): Promise<void> => {
|
||||
const usersFromAnalytics = await Analytics.findOneByTypeBeforeDate({
|
||||
type: 'users',
|
||||
date: convertDateToInt(date),
|
||||
});
|
||||
if (!usersFromAnalytics) {
|
||||
const startOfPeriod = moment(date).subtract(90, 'days').toDate();
|
||||
const users = await Users.getTotalOfRegisteredUsersByDate({
|
||||
start: startOfPeriod,
|
||||
end: date,
|
||||
});
|
||||
users.forEach((user) =>
|
||||
Analytics.insertOne({
|
||||
...user,
|
||||
date: parseInt(user.date),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const findWeeklyUsersRegisteredData = async ({
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}): Promise<{
|
||||
days: { day: Date; users: number }[];
|
||||
period: {
|
||||
count: number;
|
||||
variation: number;
|
||||
};
|
||||
yesterday: {
|
||||
count: number;
|
||||
variation: number;
|
||||
};
|
||||
}> => {
|
||||
const daysBetweenDates = diffBetweenDaysInclusive(end, start);
|
||||
const endOfLastWeek = moment(start).clone().subtract(1, 'days').toDate();
|
||||
const startOfLastWeek = moment(endOfLastWeek).clone().subtract(daysBetweenDates, 'days').toDate();
|
||||
const today = convertDateToInt(end);
|
||||
const yesterday = convertDateToInt(moment(end).clone().subtract(1, 'days').toDate());
|
||||
const currentPeriodUsers = await Analytics.getTotalOfRegisteredUsersByDate({
|
||||
start: convertDateToInt(start),
|
||||
end: convertDateToInt(end),
|
||||
options: { count: daysBetweenDates, sort: { _id: -1 } },
|
||||
}).toArray();
|
||||
const lastPeriodUsers = await Analytics.getTotalOfRegisteredUsersByDate({
|
||||
start: convertDateToInt(startOfLastWeek),
|
||||
end: convertDateToInt(endOfLastWeek),
|
||||
options: { count: daysBetweenDates, sort: { _id: -1 } },
|
||||
}).toArray();
|
||||
const yesterdayUsers = currentPeriodUsers.find((item) => item._id === yesterday)?.users || 0;
|
||||
const todayUsers = currentPeriodUsers.find((item) => item._id === today)?.users || 0;
|
||||
const currentPeriodTotalUsers = getTotalOfWeekItems(currentPeriodUsers, 'users');
|
||||
const lastPeriodTotalUsers = getTotalOfWeekItems(lastPeriodUsers, 'users');
|
||||
return {
|
||||
days: currentPeriodUsers.map((day) => ({ day: convertIntToDate(day._id), users: day.users })),
|
||||
period: {
|
||||
count: currentPeriodTotalUsers,
|
||||
variation: currentPeriodTotalUsers - lastPeriodTotalUsers,
|
||||
},
|
||||
yesterday: {
|
||||
count: yesterdayUsers,
|
||||
variation: todayUsers - yesterdayUsers,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createDestructuredDate = (
|
||||
input: moment.MomentInput,
|
||||
): {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
} => {
|
||||
const date = moment(input);
|
||||
|
||||
return {
|
||||
year: date.year(),
|
||||
month: date.month() + 1,
|
||||
day: date.date(),
|
||||
};
|
||||
};
|
||||
|
||||
export const findActiveUsersMonthlyData = async ({
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}): Promise<{
|
||||
month: {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
usersList: IUser['_id'][];
|
||||
users: number;
|
||||
}[];
|
||||
}> => ({
|
||||
month: await Sessions.getActiveUsersOfPeriodByDayBetweenDates({
|
||||
start: createDestructuredDate(start),
|
||||
end: createDestructuredDate(end),
|
||||
}),
|
||||
});
|
||||
|
||||
export const findBusiestsChatsInADayByHours = async ({
|
||||
start,
|
||||
}: {
|
||||
start: Date;
|
||||
}): Promise<{
|
||||
hours: {
|
||||
hour: number;
|
||||
users: number;
|
||||
}[];
|
||||
}> => ({
|
||||
hours: await Sessions.getBusiestTimeWithinHoursPeriod({
|
||||
start: moment(start).subtract(24, 'hours').toDate(),
|
||||
end: start,
|
||||
groupSize: 2,
|
||||
}),
|
||||
});
|
||||
|
||||
export const findBusiestsChatsWithinAWeek = async ({
|
||||
start,
|
||||
}: {
|
||||
start: Date;
|
||||
}): Promise<{
|
||||
month: {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
users: number;
|
||||
}[];
|
||||
}> => ({
|
||||
month: await Sessions.getTotalOfSessionsByDayBetweenDates({
|
||||
start: createDestructuredDate(moment(start).subtract(7, 'days')),
|
||||
end: createDestructuredDate(start),
|
||||
}),
|
||||
});
|
||||
|
||||
export const findUserSessionsByHourWithinAWeek = async ({
|
||||
start,
|
||||
end,
|
||||
}: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}): Promise<{
|
||||
week: {
|
||||
hour: number;
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
users: number;
|
||||
}[];
|
||||
}> => ({
|
||||
week: await Sessions.getTotalOfSessionByHourAndDayBetweenDates({
|
||||
start,
|
||||
end,
|
||||
}),
|
||||
});
|
||||
110
apps/meteor/ee/server/lib/ldap/Manager.spec.ts
Normal file
110
apps/meteor/ee/server/lib/ldap/Manager.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { expect } from 'chai';
|
||||
import { beforeEach, describe, it } from 'mocha';
|
||||
import proxyquire from 'proxyquire';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const settingsStub = { get: sinon.stub() };
|
||||
const roomsStub = { findOneByNonValidatedName: sinon.stub() };
|
||||
const subscriptionsStub = { findOneByRoomIdAndUserId: sinon.stub() };
|
||||
const addUserToRoomStub = sinon.stub();
|
||||
const removeUserFromRoomStub = sinon.stub();
|
||||
const loggerStub = { debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() };
|
||||
|
||||
const { LDAPEEManager } = proxyquire.noCallThru().load('./Manager', {
|
||||
'@rocket.chat/core-services': { Abac: {}, Team: {} },
|
||||
'@rocket.chat/license': { License: { hasModule: () => false } },
|
||||
'@rocket.chat/models': { Users: {}, Roles: {}, Subscriptions: subscriptionsStub, Rooms: roomsStub },
|
||||
'../../../../server/lib/import/definitions/IConversionCallbacks': {},
|
||||
'../../../../server/settings': { settings: settingsStub },
|
||||
'../../../../server/lib/utils/lib/getValidRoomName': { getValidRoomName: (name: string) => Promise.resolve(name) },
|
||||
'../../../../lib/utils/arrayUtils': { ensureArray: (value: unknown) => (Array.isArray(value) ? value : [value]) },
|
||||
'../../../../server/lib/ldap/Connection': { LDAPConnection: class {} },
|
||||
'../../../../server/lib/ldap/Logger': { logger: loggerStub, searchLogger: loggerStub, mapLogger: loggerStub },
|
||||
'../../../../server/lib/ldap/Manager': { LDAPManager: class {} },
|
||||
'../../../../server/lib/ldap/UserConverter': { LDAPUserConverter: class {} },
|
||||
'../../../../server/lib/rooms/addUserToRoom': { addUserToRoom: addUserToRoomStub },
|
||||
'../../../../server/lib/rooms/createRoom': { createRoom: sinon.stub() },
|
||||
'../../../../server/lib/rooms/removeUserFromRoom': { removeUserFromRoom: removeUserFromRoomStub },
|
||||
'../../../../server/lib/users/setUserActiveStatus': { setUserActiveStatus: sinon.stub() },
|
||||
'../syncUserRoles': { syncUserRoles: sinon.stub() },
|
||||
'./copyCustomFieldsLDAP': { copyCustomFieldsLDAP: sinon.stub() },
|
||||
});
|
||||
|
||||
const user = { _id: 'userId', username: 'john' };
|
||||
|
||||
const channelsMap = {
|
||||
groupA: 'channel-a',
|
||||
deadGroup: 'missing-channel',
|
||||
groupB: 'channel-b',
|
||||
};
|
||||
|
||||
const setupSettings = (map: Record<string, string>) => {
|
||||
settingsStub.get.reset();
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels').returns(true);
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_Enforce_AutoChannels').returns(true);
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_ChannelsMap').returns(JSON.stringify(map));
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_Filter').returns('');
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_BaseDN').returns('');
|
||||
settingsStub.get.withArgs('LDAP_Sync_User_Data_Channels_GroupMembershipValidationStrategy').returns('once');
|
||||
settingsStub.get.withArgs('ABAC_Enabled').returns(false);
|
||||
};
|
||||
|
||||
const syncUserChannels = (ldapUserGroups: string[]) => {
|
||||
const groupsStub = sinon.stub(LDAPEEManager as any, 'getLdapGroupsByUsername').resolves(ldapUserGroups);
|
||||
return (LDAPEEManager as any).syncUserChannels({}, user, 'dn').finally(() => groupsStub.restore());
|
||||
};
|
||||
|
||||
describe('LDAPEEManager syncUserChannels', () => {
|
||||
beforeEach(() => {
|
||||
roomsStub.findOneByNonValidatedName.reset();
|
||||
subscriptionsStub.findOneByRoomIdAndUserId.reset();
|
||||
addUserToRoomStub.reset();
|
||||
removeUserFromRoomStub.reset();
|
||||
setupSettings(channelsMap);
|
||||
|
||||
roomsStub.findOneByNonValidatedName.withArgs('channel-a').resolves({ _id: 'ridA' });
|
||||
roomsStub.findOneByNonValidatedName.withArgs('channel-b').resolves({ _id: 'ridB' });
|
||||
roomsStub.findOneByNonValidatedName.withArgs('missing-channel').resolves(null);
|
||||
subscriptionsStub.findOneByRoomIdAndUserId.resolves({ _id: 'subId' });
|
||||
});
|
||||
|
||||
it('should remove the user from remaining channels when an earlier mapped channel cannot be resolved', async () => {
|
||||
await syncUserChannels(['groupA']);
|
||||
|
||||
expect(addUserToRoomStub.calledWith('ridA', user)).to.be.true;
|
||||
expect(removeUserFromRoomStub.calledWith('ridB', user)).to.be.true;
|
||||
});
|
||||
|
||||
it('should skip Team channels on removal without aborting the rest of the pass', async () => {
|
||||
roomsStub.findOneByNonValidatedName.withArgs('missing-channel').resolves({ _id: 'ridTeam', teamMain: true });
|
||||
|
||||
await syncUserChannels(['groupA']);
|
||||
|
||||
expect(removeUserFromRoomStub.calledWith('ridTeam', user)).to.be.false;
|
||||
expect(removeUserFromRoomStub.calledWith('ridB', user)).to.be.true;
|
||||
});
|
||||
|
||||
it('should keep a channel that was added in the same sync when another group maps it for removal, and still remove the rest', async () => {
|
||||
setupSettings({ groupA: 'channel-a', groupB: 'channel-a', groupC: 'channel-c' });
|
||||
roomsStub.findOneByNonValidatedName.withArgs('channel-c').resolves({ _id: 'ridC' });
|
||||
|
||||
await syncUserChannels(['groupA']);
|
||||
|
||||
expect(addUserToRoomStub.calledWith('ridA', user)).to.be.true;
|
||||
expect(removeUserFromRoomStub.calledWith('ridA', user)).to.be.false;
|
||||
expect(removeUserFromRoomStub.calledWith('ridC', user)).to.be.true;
|
||||
});
|
||||
|
||||
it('should keep adding and removing channels when an unresolved channel fails to be created on the add pass', async () => {
|
||||
sinon.stub(LDAPEEManager as any, 'createRoomForSync').resolves(undefined);
|
||||
|
||||
try {
|
||||
await syncUserChannels(['deadGroup', 'groupA']);
|
||||
} finally {
|
||||
((LDAPEEManager as any).createRoomForSync as sinon.SinonStub).restore();
|
||||
}
|
||||
|
||||
expect(addUserToRoomStub.calledWith('ridA', user)).to.be.true;
|
||||
expect(removeUserFromRoomStub.calledWith('ridB', user)).to.be.true;
|
||||
});
|
||||
});
|
||||
825
apps/meteor/ee/server/lib/ldap/Manager.ts
Normal file
825
apps/meteor/ee/server/lib/ldap/Manager.ts
Normal file
@@ -0,0 +1,825 @@
|
||||
import { Abac, Team } from '@rocket.chat/core-services';
|
||||
import type { ILDAPEntry, IUser, IRoom, IRole, IImportUser, IImportRecord } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Users, Roles, Subscriptions as SubscriptionsRaw, Rooms } from '@rocket.chat/models';
|
||||
import type ldapjs from 'ldapjs';
|
||||
import type { FindCursor } from 'mongodb';
|
||||
|
||||
import { copyCustomFieldsLDAP } from './copyCustomFieldsLDAP';
|
||||
import { ensureArray } from '../../../../lib/utils/arrayUtils';
|
||||
import type {
|
||||
ImporterAfterImportCallback,
|
||||
ImporterBeforeImportCallback,
|
||||
} from '../../../../server/lib/import/definitions/IConversionCallbacks';
|
||||
import { LDAPConnection } from '../../../../server/lib/ldap/Connection';
|
||||
import { logger, searchLogger, mapLogger } from '../../../../server/lib/ldap/Logger';
|
||||
import { LDAPManager } from '../../../../server/lib/ldap/Manager';
|
||||
import { LDAPUserConverter } from '../../../../server/lib/ldap/UserConverter';
|
||||
import { addUserToRoom } from '../../../../server/lib/rooms/addUserToRoom';
|
||||
import { createRoom } from '../../../../server/lib/rooms/createRoom';
|
||||
import { removeUserFromRoom } from '../../../../server/lib/rooms/removeUserFromRoom';
|
||||
import { setUserActiveStatus } from '../../../../server/lib/users/setUserActiveStatus';
|
||||
import { getValidRoomName } from '../../../../server/lib/utils/lib/getValidRoomName';
|
||||
import { settings } from '../../../../server/settings';
|
||||
import { syncUserRoles } from '../syncUserRoles';
|
||||
|
||||
export class LDAPEEManager extends LDAPManager {
|
||||
public static async sync(): Promise<void> {
|
||||
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Background_Sync') !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createNewUsers = settings.get<boolean>('LDAP_Background_Sync_Import_New_Users') ?? true;
|
||||
const updateExistingUsers = settings.get<boolean>('LDAP_Background_Sync_Keep_Existant_Users_Updated') ?? true;
|
||||
let disableMissingUsers = updateExistingUsers && (settings.get<boolean>('LDAP_Background_Sync_Disable_Missing_Users') ?? false);
|
||||
const mergeExistingUsers = settings.get<boolean>('LDAP_Background_Sync_Merge_Existent_Users') ?? false;
|
||||
|
||||
const options = this.getConverterOptions();
|
||||
options.skipExistingUsers = !updateExistingUsers;
|
||||
options.skipNewUsers = !createNewUsers;
|
||||
|
||||
const ldap = new LDAPConnection();
|
||||
const converter = new LDAPUserConverter(options);
|
||||
const touchedUsers = new Set<IUser['_id']>();
|
||||
|
||||
try {
|
||||
await ldap.connect();
|
||||
|
||||
if (createNewUsers || mergeExistingUsers) {
|
||||
await this.importNewUsers(ldap, converter);
|
||||
} else if (updateExistingUsers) {
|
||||
await this.updateExistingUsers(ldap, converter, disableMissingUsers);
|
||||
// Missing users will have been disabled automatically by the update operation, so no need to do a separate query for them
|
||||
disableMissingUsers = false;
|
||||
}
|
||||
|
||||
const membersOfGroupFilter = await ldap.searchMembersOfGroupFilter();
|
||||
|
||||
await converter.convertData({
|
||||
beforeImportFn: (async ({ options }: IImportRecord): Promise<boolean> => {
|
||||
if (!ldap.options.groupFilterEnabled || !ldap.options.groupFilterGroupMemberFormat) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const memberFormat = ldap.options.groupFilterGroupMemberFormat
|
||||
?.replace(/#{username}/g, options?.username || '#{username}')
|
||||
.replace(/#{userdn}/g, options?.dn || '#{userdn}');
|
||||
|
||||
return membersOfGroupFilter.includes(memberFormat);
|
||||
}) as ImporterBeforeImportCallback,
|
||||
afterImportFn: (async ({ data }, isNewRecord: boolean): Promise<void> => {
|
||||
if (data._id) {
|
||||
touchedUsers.add(data._id);
|
||||
}
|
||||
await this.advancedSync(ldap, data as IImportUser, converter, isNewRecord);
|
||||
}) as ImporterAfterImportCallback,
|
||||
});
|
||||
|
||||
if (disableMissingUsers) {
|
||||
await this.disableMissingUsers([...touchedUsers]);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
|
||||
ldap.disconnect();
|
||||
}
|
||||
|
||||
public static async syncAvatars(): Promise<void> {
|
||||
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Background_Sync_Avatars') !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
await this.updateUserAvatars(ldap);
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static async syncAbacAttributes(): Promise<void> {
|
||||
if (
|
||||
!settings.get('LDAP_Enable') ||
|
||||
!settings.get('LDAP_Background_Sync_ABAC_Attributes') ||
|
||||
!License.hasModule('abac') ||
|
||||
!settings.get('ABAC_Enabled') ||
|
||||
settings.get('ABAC_PDP_Type') === 'virtru'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
await this.updateUserAbacAttributes(ldap);
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static async syncUsersAbacAttributes(users: FindCursor<IUser>): Promise<void> {
|
||||
if (
|
||||
!settings.get('LDAP_Enable') ||
|
||||
!License.hasModule('abac') ||
|
||||
!settings.get('ABAC_Enabled') ||
|
||||
settings.get('ABAC_PDP_Type') === 'virtru'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
logger.debug({ msg: 'Starting ABAC attributes sync for LDAP users' });
|
||||
for await (const user of users) {
|
||||
await this.syncUserAbacAttribute(ldap, user);
|
||||
}
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static validateLDAPTeamsMappingChanges(json: string): void {
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mustBeAnArrayOfStrings = (array: Array<string>): boolean =>
|
||||
Boolean(Array.isArray(array) && array.length && array.every((item) => typeof item === 'string'));
|
||||
const mappedTeams = this.parseJson(json);
|
||||
if (!mappedTeams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedRocketChatTeams = Object.values(mappedTeams);
|
||||
const validStructureMapping = mappedRocketChatTeams.every(mustBeAnArrayOfStrings);
|
||||
if (!validStructureMapping) {
|
||||
throw new Error(
|
||||
'Please verify your mapping for LDAP X RocketChat Teams. The structure is invalid, the structure should be an object like: {key: LdapTeam, value: [An array of rocket.chat teams]}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static validateLDAPABACAttributeMap(json: string): void {
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedAttributes = this.parseJson(json);
|
||||
|
||||
// attributes are { key: value } with key being the ldap attribute and value being the abac attribute in rocketchat
|
||||
// both strings
|
||||
// There's no need for the attribute to exist in rocketchat, we just add whatever the admin wants to map
|
||||
|
||||
if (!mappedAttributes || Object.keys(mappedAttributes).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validStructureMapping = Object.entries(mappedAttributes).every(
|
||||
([key, value]) => typeof key === 'string' && typeof value === 'string',
|
||||
);
|
||||
|
||||
if (!validStructureMapping) {
|
||||
throw new Error(
|
||||
'Please verify your mapping for LDAP X RocketChat ABAC Attributes. The structure is invalid, the structure should be an object like: {key: LdapAttribute, value: RocketChatAbacAttribute}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static async syncAvatarAndAbacAttributes(): Promise<void> {
|
||||
const syncAvatars = settings.get('LDAP_Background_Sync_Avatars');
|
||||
const syncAbac = settings.get('LDAP_Background_Sync_ABAC_Attributes') && License.hasModule('abac') && settings.get('ABAC_Enabled');
|
||||
const abacMapping = syncAbac && this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
|
||||
|
||||
if (!syncAvatars && !syncAbac) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
const users = Users.findLDAPUsers();
|
||||
for await (const user of users) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (syncAvatars) {
|
||||
await LDAPManager.syncUserAvatar(user, ldapUser);
|
||||
}
|
||||
|
||||
if (syncAbac && abacMapping) {
|
||||
await Abac.addSubjectAttributes(user, ldapUser, abacMapping, undefined);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static async syncLogout(): Promise<void> {
|
||||
if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Sync_AutoLogout_Enabled') !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ldap = new LDAPConnection();
|
||||
await ldap.connect();
|
||||
|
||||
try {
|
||||
await this.logoutDeactivatedUsers(ldap);
|
||||
} finally {
|
||||
ldap.disconnect();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
public static async advancedSyncForUser(ldap: LDAPConnection, user: IUser, isNewRecord: boolean, dn: string): Promise<void> {
|
||||
try {
|
||||
await this.syncUserRoles(ldap, user, dn);
|
||||
await this.syncUserChannels(ldap, user, dn);
|
||||
await this.syncUserTeams(ldap, user, dn, isNewRecord);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Advanced Sync failed for user', dn, err });
|
||||
}
|
||||
}
|
||||
|
||||
private static async advancedSync(
|
||||
ldap: LDAPConnection,
|
||||
importUser: IImportUser,
|
||||
converter: LDAPUserConverter,
|
||||
isNewRecord: boolean,
|
||||
): Promise<void> {
|
||||
const user = await converter.findExistingUser(importUser);
|
||||
if (!user?.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dn = importUser.importIds[0];
|
||||
return this.advancedSyncForUser(ldap, user, isNewRecord, dn);
|
||||
}
|
||||
|
||||
private static async isUserInGroup(
|
||||
ldap: LDAPConnection,
|
||||
baseDN: string,
|
||||
filter: string,
|
||||
{ dn, username }: { dn: string; username: string },
|
||||
groupName: string,
|
||||
): Promise<boolean> {
|
||||
if (!filter || !baseDN) {
|
||||
logger.error('Please setup LDAP Group Filter and LDAP Group BaseDN in LDAP Settings.');
|
||||
return false;
|
||||
}
|
||||
const searchOptions: ldapjs.SearchOptions = {
|
||||
filter: filter
|
||||
.replace(/#{username}/g, username)
|
||||
.replace(/#{groupName}/g, groupName)
|
||||
.replace(/#{userdn}/g, dn.replace(/\\/g, '\\5c')),
|
||||
scope: 'sub',
|
||||
};
|
||||
|
||||
const result = await ldap.searchRaw(baseDN, searchOptions);
|
||||
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
logger.debug({ msg: 'User is not in group', username, groupName });
|
||||
} else {
|
||||
logger.debug({ msg: 'User is in group', username, groupName });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static parseJson(json: string): Record<string, any> | undefined {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Unexpected error', err });
|
||||
}
|
||||
}
|
||||
|
||||
private static async syncUserRoles(ldap: LDAPConnection, user: IUser, dn: string): Promise<void> {
|
||||
const { username } = user;
|
||||
if (!username) {
|
||||
logger.debug('User has no username');
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSyncUserRoles = settings.get<boolean>('LDAP_Sync_User_Data_Roles') ?? false;
|
||||
const syncUserRolesAutoRemove = settings.get<boolean>('LDAP_Sync_User_Data_Roles_AutoRemove') ?? false;
|
||||
const syncUserRolesFieldMap = (settings.get<string>('LDAP_Sync_User_Data_RolesMap') ?? '').trim();
|
||||
const syncUserRolesFilter = (settings.get<string>('LDAP_Sync_User_Data_Roles_Filter') ?? '').trim();
|
||||
const syncUserRolesBaseDN = (settings.get<string>('LDAP_Sync_User_Data_Roles_BaseDN') ?? '').trim();
|
||||
const searchStrategy = settings.get<'once' | 'each_group'>('LDAP_Sync_User_Data_Roles_GroupMembershipValidationStrategy');
|
||||
|
||||
if (!shouldSyncUserRoles || !syncUserRolesFieldMap) {
|
||||
logger.debug('not syncing user roles');
|
||||
return;
|
||||
}
|
||||
|
||||
const roles = await Roles.find(
|
||||
{},
|
||||
{
|
||||
projection: {
|
||||
_id: 1,
|
||||
name: 1,
|
||||
},
|
||||
},
|
||||
).toArray();
|
||||
|
||||
if (!roles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupsToRolesMap = this.parseJson(syncUserRolesFieldMap);
|
||||
if (!groupsToRolesMap) {
|
||||
logger.debug('missing group role mapping');
|
||||
return;
|
||||
}
|
||||
|
||||
const ldapGroups = Object.keys(groupsToRolesMap);
|
||||
const roleList: Array<IRole['_id']> = [];
|
||||
const roleIdsList: Array<IRole['_id']> = [];
|
||||
const allowedRoles: Array<IRole['_id']> = this.getDataMappedByLdapGroups(groupsToRolesMap, ldapGroups)
|
||||
.map((role) => role.split(/\.(.+)/)[0])
|
||||
.reduce((allowedRolesIds: string[], roleIdOrName: string) => {
|
||||
const role = roles.find((role) => role._id === roleIdOrName) ?? roles.find((role) => role.name === roleIdOrName);
|
||||
if (role) {
|
||||
allowedRolesIds.push(role._id);
|
||||
}
|
||||
return allowedRolesIds;
|
||||
}, []);
|
||||
|
||||
if (searchStrategy === 'once') {
|
||||
const ldapUserGroups = await this.getLdapGroupsByUsername(ldap, username, dn, syncUserRolesBaseDN, syncUserRolesFilter);
|
||||
roleList.push(...this.getDataMappedByLdapGroups(groupsToRolesMap, ldapUserGroups));
|
||||
} else if (searchStrategy === 'each_group') {
|
||||
for await (const ldapGroup of ldapGroups) {
|
||||
if (await this.isUserInGroup(ldap, syncUserRolesBaseDN, syncUserRolesFilter, { dn, username }, ldapGroup)) {
|
||||
roleList.push(...ensureArray<string>(groupsToRolesMap[ldapGroup]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await (const nonValidatedRole of roleList) {
|
||||
const [roleIdOrName] = nonValidatedRole.split(/\.(.+)/);
|
||||
|
||||
const role = roles.find((role) => role._id === roleIdOrName) ?? roles.find((role) => role.name === roleIdOrName);
|
||||
if (role) {
|
||||
roleIdsList.push(role._id);
|
||||
}
|
||||
}
|
||||
|
||||
await syncUserRoles(user._id, roleIdsList, {
|
||||
allowedRoles,
|
||||
skipRemovingRoles: !syncUserRolesAutoRemove,
|
||||
});
|
||||
}
|
||||
|
||||
private static async createRoomForSync(channel: string): Promise<IRoom | undefined> {
|
||||
logger.debug({ msg: "Channel doesn't exist, creating it", channel });
|
||||
|
||||
const roomOwner = settings.get<string>('LDAP_Sync_User_Data_Channels_Admin') || '';
|
||||
|
||||
const user = await Users.findOneByUsernameIgnoringCase(roomOwner);
|
||||
if (!user) {
|
||||
logger.error({ msg: 'Unable to find user to own channel', roomOwner, channel });
|
||||
return;
|
||||
}
|
||||
|
||||
const room = await createRoom('c', channel, user, [], false, false, {
|
||||
customFields: { ldap: true },
|
||||
});
|
||||
if (!room?.rid) {
|
||||
logger.error({ msg: 'Unable to auto-create channel during LDAP sync', channel });
|
||||
return;
|
||||
}
|
||||
|
||||
room._id = room.rid;
|
||||
return room;
|
||||
}
|
||||
|
||||
private static async syncUserChannels(ldap: LDAPConnection, user: IUser, dn: string): Promise<void> {
|
||||
const syncUserChannels = settings.get<boolean>('LDAP_Sync_User_Data_Channels') ?? false;
|
||||
const syncUserChannelsRemove = settings.get<boolean>('LDAP_Sync_User_Data_Channels_Enforce_AutoChannels') ?? false;
|
||||
const syncUserChannelsFieldMap = (settings.get<string>('LDAP_Sync_User_Data_ChannelsMap') ?? '').trim();
|
||||
const syncUserChannelsFilter = (settings.get<string>('LDAP_Sync_User_Data_Channels_Filter') ?? '').trim();
|
||||
const syncUserChannelsBaseDN = (settings.get<string>('LDAP_Sync_User_Data_Channels_BaseDN') ?? '').trim();
|
||||
const searchStrategy = settings.get<'once' | 'each_group'>('LDAP_Sync_User_Data_Channels_GroupMembershipValidationStrategy');
|
||||
|
||||
if (!syncUserChannels || !syncUserChannelsFieldMap) {
|
||||
logger.debug('not syncing groups to channels');
|
||||
return;
|
||||
}
|
||||
|
||||
const groupsToRoomsMap = this.parseJson(syncUserChannelsFieldMap);
|
||||
if (!groupsToRoomsMap) {
|
||||
logger.debug('missing group channel mapping');
|
||||
return;
|
||||
}
|
||||
|
||||
const { username } = user;
|
||||
if (!username) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('syncing user channels');
|
||||
const ldapGroups = Object.keys(groupsToRoomsMap);
|
||||
const ldapUserGroups: string[] = [];
|
||||
const channelsToAdd = new Set<string>();
|
||||
const userChannelsNames: string[] = [];
|
||||
|
||||
if (searchStrategy === 'once') {
|
||||
ldapUserGroups.push(...(await this.getLdapGroupsByUsername(ldap, username, dn, syncUserChannelsBaseDN, syncUserChannelsFilter)));
|
||||
userChannelsNames.push(...this.getDataMappedByLdapGroups(groupsToRoomsMap, ldapUserGroups));
|
||||
} else if (searchStrategy === 'each_group') {
|
||||
for await (const ldapGroup of ldapGroups) {
|
||||
if (await this.isUserInGroup(ldap, syncUserChannelsBaseDN, syncUserChannelsFilter, { dn, username }, ldapGroup)) {
|
||||
userChannelsNames.push(...ensureArray<string>(groupsToRoomsMap[ldapGroup]));
|
||||
ldapUserGroups.push(ldapGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await (const userChannelName of userChannelsNames) {
|
||||
try {
|
||||
const name = await getValidRoomName(userChannelName.trim(), undefined, { allowDuplicates: true });
|
||||
const room = (await Rooms.findOneByNonValidatedName(name)) || (await this.createRoomForSync(userChannelName));
|
||||
if (!room) {
|
||||
logger.debug({ msg: 'Unable to resolve mapped channel for sync', userChannelName, username });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) {
|
||||
logger.error({ msg: 'Cannot add user to channel. Channel is ABAC managed', userChannelName });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (room.teamMain) {
|
||||
logger.error({ msg: "Can't add user to channel because it is a team", userChannelName });
|
||||
} else {
|
||||
channelsToAdd.add(room._id);
|
||||
await addUserToRoom(room._id, user);
|
||||
logger.debug({ msg: 'Synced user channel from LDAP', roomId: room._id, username });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ msg: 'Failed to sync user room', username, userChannelName });
|
||||
logger.error({ err });
|
||||
}
|
||||
}
|
||||
|
||||
if (syncUserChannelsRemove) {
|
||||
const notInUserGroups = ldapGroups.filter((ldapGroup) => !ldapUserGroups.includes(ldapGroup));
|
||||
const notMappedRooms = this.getDataMappedByLdapGroups(groupsToRoomsMap, notInUserGroups);
|
||||
|
||||
const roomsToRemove = new Set<string>(notMappedRooms);
|
||||
for await (const roomName of roomsToRemove) {
|
||||
const name = await getValidRoomName(roomName.trim(), undefined, { allowDuplicates: true });
|
||||
const room = await Rooms.findOneByNonValidatedName(name);
|
||||
if (!room || room.teamMain || channelsToAdd.has(room._id)) {
|
||||
logger.debug({ msg: 'Skipping channel on removal sync', roomName, username });
|
||||
continue;
|
||||
}
|
||||
|
||||
const subscription = await SubscriptionsRaw.findOneByRoomIdAndUserId(room._id, user._id);
|
||||
if (subscription) {
|
||||
await removeUserFromRoom(room._id, user);
|
||||
logger.debug({ msg: 'Removed user from channel', username, roomId: room._id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async syncUserTeams(ldap: LDAPConnection, user: IUser, dn: string, isNewRecord: boolean): Promise<void> {
|
||||
if (!user.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapTeams =
|
||||
settings.get<boolean>('LDAP_Enable_LDAP_Groups_To_RC_Teams') &&
|
||||
(isNewRecord || settings.get<boolean>('LDAP_Validate_Teams_For_Each_Login'));
|
||||
if (!mapTeams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDN = (settings.get<string>('LDAP_Teams_BaseDN') ?? '').trim() || ldap.options.baseDN;
|
||||
const filter = settings.get<string>('LDAP_Query_To_Get_User_Teams');
|
||||
const groupAttributeName = settings.get<string>('LDAP_Teams_Name_Field');
|
||||
const ldapUserTeams = await this.getLdapGroupsByUsername(ldap, user.username, dn, baseDN, filter, groupAttributeName);
|
||||
const mapJson = settings.get<string>('LDAP_Groups_To_Rocket_Chat_Teams');
|
||||
if (!mapJson) {
|
||||
return;
|
||||
}
|
||||
const map = this.parseJson(mapJson) as Record<string, string>;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teamNames = this.getDataMappedByLdapGroups(map, ldapUserTeams);
|
||||
|
||||
const allTeamNames = [...new Set(Object.values(map).flat())];
|
||||
const allTeams = await Team.listByNames(allTeamNames, { projection: { _id: 1, name: 1 } });
|
||||
|
||||
const inTeamIds = allTeams.filter(({ name }) => teamNames.includes(name)).map(({ _id }) => _id);
|
||||
const notInTeamIds = allTeams.filter(({ name }) => !teamNames.includes(name)).map(({ _id }) => _id);
|
||||
|
||||
const currentTeams = await Team.listTeamsBySubscriberUserId(user._id, {
|
||||
projection: { teamId: 1 },
|
||||
});
|
||||
const currentTeamIds = currentTeams?.map(({ teamId }) => teamId);
|
||||
const teamsToRemove = currentTeamIds?.filter((teamId) => notInTeamIds.includes(teamId));
|
||||
let teamsToAdd = inTeamIds.filter((teamId) => !currentTeamIds?.includes(teamId));
|
||||
|
||||
if (settings.get('ABAC_Enabled')) {
|
||||
const roomsWithAbacAttributes = await Rooms.findPrivateRoomsByIdsWithAbacAttributes(
|
||||
allTeams.filter((t) => teamsToAdd.includes(t._id)).map((t) => t.roomId),
|
||||
{ projection: { teamId: 1 } },
|
||||
)
|
||||
.map((r) => r.teamId)
|
||||
.toArray();
|
||||
|
||||
logger.debug({ msg: 'Some teams will be ignored from sync because they are abac managed', roomsWithAbacAttributes });
|
||||
|
||||
teamsToAdd = teamsToAdd.filter((teamId) => !roomsWithAbacAttributes.includes(teamId));
|
||||
if (!teamsToAdd.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Team.insertMemberOnTeams(user._id, teamsToAdd);
|
||||
if (teamsToRemove) {
|
||||
await Team.removeMemberFromTeams(user._id, teamsToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
private static getDataMappedByLdapGroups(map: Record<string, string>, ldapGroups: Array<string>): Array<string> {
|
||||
const mappedLdapGroups = Object.keys(map);
|
||||
const filteredMappedLdapGroups = ldapGroups.filter((ldapGroup) => mappedLdapGroups.includes(ldapGroup));
|
||||
|
||||
if (filteredMappedLdapGroups.length < ldapGroups.length) {
|
||||
const unmappedLdapGroups = ldapGroups.filter((ldapGroup) => !mappedLdapGroups.includes(ldapGroup));
|
||||
logger.error({ msg: 'The following LDAP groups are not mapped in Rocket.Chat', unmappedLdapGroups });
|
||||
}
|
||||
|
||||
if (!filteredMappedLdapGroups.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(filteredMappedLdapGroups.map((ldapGroup) => map[ldapGroup]).flat())];
|
||||
}
|
||||
|
||||
private static async getLdapGroupsByUsername(
|
||||
ldap: LDAPConnection,
|
||||
username: string,
|
||||
userDN: string,
|
||||
baseDN: string,
|
||||
filter: string,
|
||||
groupAttributeName?: string,
|
||||
): Promise<Array<string>> {
|
||||
if (!filter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchOptions = {
|
||||
filter: filter.replace(/#{username}/g, username).replace(/#{userdn}/g, userDN.replace(/\\/g, '\\5c')),
|
||||
scope: ldap.options.userSearchScope || 'sub',
|
||||
sizeLimit: ldap.options.searchSizeLimit,
|
||||
};
|
||||
|
||||
const attributeNames = groupAttributeName ? groupAttributeName.split(',').map((attributeName) => attributeName.trim()) : ['ou', 'cn'];
|
||||
|
||||
const ldapUserGroups = await ldap.searchRaw(baseDN, searchOptions);
|
||||
if (!Array.isArray(ldapUserGroups)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ldapUserGroups
|
||||
.map((entry) => {
|
||||
if (!entry?.raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const attributeName of attributeNames) {
|
||||
if (entry.raw[attributeName]) {
|
||||
return ldap.extractLdapAttribute(entry.raw[attributeName]) as string;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.flat();
|
||||
}
|
||||
|
||||
private static isUserDeactivated(ldapUser: ILDAPEntry): boolean {
|
||||
// EDirectory: Account manually disabled by an admin
|
||||
if (ldapUser.loginDisabled) {
|
||||
mapLogger.debug('User account was manually disabled by an admin (attribute loginDisabled)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Oracle: Account must not be allowed to authenticate
|
||||
if (ldapUser.orclIsEnabled && ldapUser.orclIsEnabled !== 'ENABLED') {
|
||||
mapLogger.debug('User must not be allowed to authenticate (attribute orclIsEnabled)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Active Directory - Account disabled by an Admin
|
||||
if (ldapUser.userAccountControl && (ldapUser.userAccountControl & 2) === 2) {
|
||||
mapLogger.debug('User account disabled by an admin (attribute userAccountControl)');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static copyActiveState(ldapUser: ILDAPEntry, userData: IImportUser): void {
|
||||
if (!ldapUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncUserState = settings.get('LDAP_Sync_User_Active_State');
|
||||
if (syncUserState === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = this.isUserDeactivated(ldapUser);
|
||||
if (deleted === userData.deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncUserState === 'disable' && !deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncUserState === 'enable' && deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
userData.deleted = deleted;
|
||||
logger.info({ msg: 'Switching user status', name: userData.name, username: userData.username, active: !deleted });
|
||||
}
|
||||
|
||||
public static copyCustomFields(ldapUser: ILDAPEntry, userData: IImportUser): void {
|
||||
return copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
customFieldsSettings: settings.get<string>('Accounts_CustomFields'),
|
||||
customFieldsMap: settings.get<string>('LDAP_CustomFieldMap'),
|
||||
syncCustomFields: settings.get<boolean>('LDAP_Sync_Custom_Fields'),
|
||||
},
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
private static async importNewUsers(ldap: LDAPConnection, converter: LDAPUserConverter): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let count = 0;
|
||||
|
||||
ldap
|
||||
.searchAllUsers<IImportUser>({
|
||||
entryCallback: (entry: ldapjs.SearchEntry): IImportUser | undefined => {
|
||||
const data = ldap.extractLdapEntryData(entry);
|
||||
count++;
|
||||
|
||||
const userData = this.mapUserData(data);
|
||||
converter.addObjectToMemory(userData, { dn: data.dn, username: this.getLdapUsername(data) });
|
||||
return userData;
|
||||
},
|
||||
endCallback: (err: any): void => {
|
||||
if (err) {
|
||||
logger.error({ err });
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ msg: 'LDAP finished loading users. Users added to importer', count });
|
||||
resolve();
|
||||
},
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
private static async updateExistingUsers(ldap: LDAPConnection, converter: LDAPUserConverter, disableMissingUsers = false): Promise<void> {
|
||||
const users = await Users.findLDAPUsers().toArray();
|
||||
for await (const user of users) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
|
||||
if (ldapUser) {
|
||||
const userData = this.mapUserData(ldapUser, user.username);
|
||||
converter.addObjectToMemory(userData, { dn: ldapUser.dn, username: this.getLdapUsername(ldapUser) });
|
||||
} else if (disableMissingUsers && user.active) {
|
||||
await setUserActiveStatus(user._id, false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async disableMissingUsers(foundUsers: IUser['_id'][]): Promise<void> {
|
||||
const userIds = (await Users.findActiveLDAPUsersExceptIds(foundUsers, { projection: { _id: 1 } }).toArray()).map(({ _id }) => _id);
|
||||
|
||||
await Promise.allSettled(userIds.map((id) => setUserActiveStatus(id, false, true)));
|
||||
}
|
||||
|
||||
private static async updateUserAvatars(ldap: LDAPConnection): Promise<void> {
|
||||
const users = await Users.findLDAPUsers().toArray();
|
||||
for await (const user of users) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await LDAPManager.syncUserAvatar(user, ldapUser);
|
||||
}
|
||||
}
|
||||
|
||||
private static async updateUserAbacAttributes(ldap: LDAPConnection): Promise<void> {
|
||||
const mapping = this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
|
||||
if (!mapping) {
|
||||
logger.error('LDAP to ABAC attribute mapping is not valid JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const user of Users.findLDAPUsers()) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await Abac.addSubjectAttributes(user, ldapUser, mapping, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private static async syncUserAbacAttribute(ldap: LDAPConnection, user: IUser): Promise<void> {
|
||||
const mapping = this.parseJson(settings.get('LDAP_ABAC_AttributeMap'));
|
||||
if (!mapping) {
|
||||
logger.error('LDAP to ABAC attribute mapping is not valid JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Abac.addSubjectAttributes(user, ldapUser, mapping, undefined);
|
||||
}
|
||||
|
||||
private static async findLDAPUser(ldap: LDAPConnection, user: IUser): Promise<ILDAPEntry | undefined> {
|
||||
if (user.services?.ldap?.id) {
|
||||
return ldap.findOneById(user.services.ldap.id, user.services.ldap.idAttribute);
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
return ldap.findOneByUsername(user.username);
|
||||
}
|
||||
|
||||
searchLogger.debug({
|
||||
msg: 'existing LDAP user not found during Sync',
|
||||
ldapId: user.services?.ldap?.id,
|
||||
ldapAttribute: user.services?.ldap?.idAttribute,
|
||||
username: user.username,
|
||||
});
|
||||
}
|
||||
|
||||
private static async logoutDeactivatedUsers(ldap: LDAPConnection): Promise<void> {
|
||||
const users = await Users.findConnectedLDAPUsers().toArray();
|
||||
|
||||
for await (const user of users) {
|
||||
const ldapUser = await this.findLDAPUser(ldap, user);
|
||||
if (!ldapUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isUserDeactivated(ldapUser)) {
|
||||
await Users.unsetLoginTokens(user._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
209
apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.spec.ts
Normal file
209
apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { IImportUser, ILDAPEntry } from '@rocket.chat/core-typings';
|
||||
import type { Logger } from '@rocket.chat/logger';
|
||||
import { expect, spy } from 'chai';
|
||||
|
||||
import { copyCustomFieldsLDAP } from './copyCustomFieldsLDAP';
|
||||
|
||||
describe('LDAP copyCustomFieldsLDAP', () => {
|
||||
it('should copy custom fields from ldapUser to rcUser', () => {
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug: () => undefined,
|
||||
error: () => undefined,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
|
||||
expect(userData).to.have.property('customFields');
|
||||
expect(userData.customFields).to.be.eql({ mappedGivenName: 'Test' });
|
||||
});
|
||||
|
||||
it('should copy custom fields from ldapUser to rcUser already having other custom fields', () => {
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
customFields: {
|
||||
custom: 'Test',
|
||||
},
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug: () => undefined,
|
||||
error: () => undefined,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
|
||||
expect(userData).to.have.property('customFields');
|
||||
expect(userData.customFields).to.be.eql({ custom: 'Test', mappedGivenName: 'Test' });
|
||||
});
|
||||
|
||||
it('should not copy custom fields from ldapUser to rcUser if syncCustomFields is false', () => {
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: false,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug: () => undefined,
|
||||
error: () => undefined,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
|
||||
expect(userData).to.not.have.property('customFields');
|
||||
});
|
||||
|
||||
it('should call logger.error if customFieldsSettings is not a valid JSON', () => {
|
||||
const debug = spy();
|
||||
const error = spy();
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: `${JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
})}}`,
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug,
|
||||
error,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
expect(error).to.have.been.called.exactly(1);
|
||||
});
|
||||
it('should call logger.error if customFieldsMap is not a valid JSON', () => {
|
||||
const debug = spy();
|
||||
const error = spy();
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: `${JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
})}}`,
|
||||
},
|
||||
{
|
||||
debug,
|
||||
error,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
expect(error).to.have.been.called.exactly(1);
|
||||
});
|
||||
|
||||
it('should call logger.debug if some custom fields are not mapped but still mapping the other fields', () => {
|
||||
const debug = spy();
|
||||
const error = spy();
|
||||
const ldapUser = {
|
||||
mail: 'test@test.com',
|
||||
givenName: 'Test',
|
||||
} as unknown as ILDAPEntry;
|
||||
|
||||
const userData = {
|
||||
name: 'Test',
|
||||
username: 'test',
|
||||
} as unknown as IImportUser;
|
||||
|
||||
copyCustomFieldsLDAP(
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
syncCustomFields: true,
|
||||
customFieldsSettings: JSON.stringify({
|
||||
mappedGivenName: { type: 'text', required: false },
|
||||
}),
|
||||
customFieldsMap: JSON.stringify({
|
||||
givenName: 'mappedGivenName',
|
||||
test: 'test',
|
||||
}),
|
||||
},
|
||||
{
|
||||
debug,
|
||||
error,
|
||||
} as unknown as Logger,
|
||||
);
|
||||
expect(debug).to.have.been.called.exactly(1);
|
||||
expect(userData).to.have.property('customFields');
|
||||
expect(userData.customFields).to.be.eql({ mappedGivenName: 'Test' });
|
||||
});
|
||||
});
|
||||
71
apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.ts
Normal file
71
apps/meteor/ee/server/lib/ldap/copyCustomFieldsLDAP.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { IImportUser, ILDAPEntry } from '@rocket.chat/core-typings';
|
||||
import type { Logger } from '@rocket.chat/logger';
|
||||
|
||||
import { replacesNestedValues } from './replacesNestedValues';
|
||||
import { getNestedProp } from '../../../../server/lib/getNestedProp';
|
||||
import { templateVarHandler } from '../../../../server/lib/utils/lib/templateVarHandler';
|
||||
|
||||
export const copyCustomFieldsLDAP = (
|
||||
{
|
||||
ldapUser,
|
||||
userData,
|
||||
customFieldsSettings,
|
||||
customFieldsMap,
|
||||
syncCustomFields,
|
||||
}: {
|
||||
ldapUser: ILDAPEntry;
|
||||
userData: IImportUser;
|
||||
syncCustomFields: boolean;
|
||||
customFieldsSettings: string;
|
||||
customFieldsMap: string;
|
||||
},
|
||||
logger: Logger,
|
||||
): void => {
|
||||
if (!syncCustomFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customFieldsMap || !customFieldsSettings) {
|
||||
if (customFieldsMap) {
|
||||
logger.debug('Skipping LDAP custom fields because there are no custom map fields configured.');
|
||||
return;
|
||||
}
|
||||
logger.debug('Skipping LDAP custom fields because there are no custom fields configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
const map: Record<string, string> = (() => {
|
||||
try {
|
||||
return JSON.parse(customFieldsMap);
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error parsing LDAP custom fields map.', err });
|
||||
}
|
||||
})();
|
||||
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
let customFields: Record<string, any>;
|
||||
try {
|
||||
customFields = JSON.parse(customFieldsSettings) as Record<string, any>;
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to parse Custom Fields', err });
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(map).forEach(([ldapField, userField]) => {
|
||||
if (!getNestedProp(customFields, userField)) {
|
||||
logger.debug({ msg: 'User attribute does not exist', userField });
|
||||
return;
|
||||
}
|
||||
|
||||
const value = templateVarHandler(ldapField, ldapUser);
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
userData.customFields = replacesNestedValues({ ...userData.customFields }, userField, value);
|
||||
});
|
||||
};
|
||||
87
apps/meteor/ee/server/lib/ldap/replacesNestedValues.spec.ts
Normal file
87
apps/meteor/ee/server/lib/ldap/replacesNestedValues.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect } from 'chai';
|
||||
|
||||
import { replacesNestedValues } from './replacesNestedValues';
|
||||
|
||||
describe('LDAP replacesNestedValues', () => {
|
||||
it('should replace shallow values', () => {
|
||||
const result = replacesNestedValues(
|
||||
{
|
||||
a: 1,
|
||||
},
|
||||
'a',
|
||||
2,
|
||||
);
|
||||
expect(result).to.eql({
|
||||
a: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace undefined values', () => {
|
||||
const result = replacesNestedValues({}, 'a', 2);
|
||||
expect(result).to.eql({
|
||||
a: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should replace nested values', () => {
|
||||
const result = replacesNestedValues(
|
||||
{
|
||||
a: {
|
||||
b: 1,
|
||||
},
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
);
|
||||
expect(result).to.eql({
|
||||
a: {
|
||||
b: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should replace undefined nested values', () => {
|
||||
const result = replacesNestedValues(
|
||||
{
|
||||
a: {},
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
);
|
||||
expect(result).to.eql({
|
||||
a: {
|
||||
b: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if the value being replaced is not an object', () => {
|
||||
expect(() =>
|
||||
replacesNestedValues(
|
||||
{
|
||||
a: [],
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
),
|
||||
).to.throw();
|
||||
expect(() =>
|
||||
replacesNestedValues(
|
||||
{
|
||||
a: 1,
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
),
|
||||
).to.throw();
|
||||
|
||||
expect(() =>
|
||||
replacesNestedValues(
|
||||
{
|
||||
a: { b: [] },
|
||||
},
|
||||
'a.b',
|
||||
2,
|
||||
),
|
||||
).to.throw();
|
||||
});
|
||||
});
|
||||
26
apps/meteor/ee/server/lib/ldap/replacesNestedValues.ts
Normal file
26
apps/meteor/ee/server/lib/ldap/replacesNestedValues.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const replacesNestedValues = (obj: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> => {
|
||||
const keys = key.split('.');
|
||||
const lastKey = keys.shift();
|
||||
|
||||
if (!lastKey) {
|
||||
throw new Error(`Failed to assign custom field: ${key}`);
|
||||
}
|
||||
|
||||
if (keys.length && obj[lastKey] !== undefined && (typeof obj[lastKey] !== 'object' || Array.isArray(obj[lastKey]))) {
|
||||
throw new Error(`Failed to assign custom field: ${key}`);
|
||||
}
|
||||
|
||||
if (keys.length === 0 && typeof obj[lastKey] === 'object') {
|
||||
throw new Error(`Failed to assign custom field: ${key}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...obj,
|
||||
...(keys.length === 0 && {
|
||||
[lastKey]: value,
|
||||
}),
|
||||
...(keys.length > 0 && {
|
||||
[lastKey]: replacesNestedValues(obj[lastKey] as Record<string, unknown>, keys.join('.'), value),
|
||||
}),
|
||||
};
|
||||
};
|
||||
43
apps/meteor/ee/server/lib/license/airGappedRestrictions.ts
Normal file
43
apps/meteor/ee/server/lib/license/airGappedRestrictions.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { AirGappedRestriction, License } from '@rocket.chat/license';
|
||||
import { Settings, Statistics } from '@rocket.chat/models';
|
||||
|
||||
import { i18n } from '../../../../server/lib/i18n';
|
||||
import { notifyOnSettingChangedById } from '../../../../server/lib/notifyListener';
|
||||
import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins';
|
||||
import { updateAuditedBySystem } from '../../../../server/settings/lib/auditedSettingUpdates';
|
||||
|
||||
const updateRestrictionSetting = async (remainingDays: number) => {
|
||||
await updateAuditedBySystem({
|
||||
reason: 'updateRestrictionSetting',
|
||||
})(Settings.updateValueById, 'Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', remainingDays);
|
||||
void notifyOnSettingChangedById('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days');
|
||||
};
|
||||
|
||||
const sendRocketCatWarningToAdmins = async (remainingDays: number) => {
|
||||
const lastDayOrNoRestrictionsAtAll = remainingDays <= 0;
|
||||
if (lastDayOrNoRestrictionsAtAll) {
|
||||
return;
|
||||
}
|
||||
if (AirGappedRestriction.isWarningPeriod(remainingDays)) {
|
||||
await sendMessagesToAdmins({
|
||||
msgs: async ({ adminUser }) => ({
|
||||
msg: i18n.t('AirGapped_Restriction_Warning', { lng: adminUser.language || 'en', remainingDays }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
AirGappedRestriction.on('remainingDays', async ({ days }: { days: number }) => {
|
||||
await updateRestrictionSetting(days);
|
||||
await sendRocketCatWarningToAdmins(days);
|
||||
});
|
||||
|
||||
License.onValidateLicense(async () => {
|
||||
const token = await Statistics.findLastStatsToken();
|
||||
void AirGappedRestriction.computeRestriction(token);
|
||||
});
|
||||
|
||||
License.onRemoveLicense(async () => {
|
||||
const token = await Statistics.findLastStatsToken();
|
||||
void AirGappedRestriction.computeRestriction(token);
|
||||
});
|
||||
50
apps/meteor/ee/server/lib/license/canEnableApp.ts
Normal file
50
apps/meteor/ee/server/lib/license/canEnableApp.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
|
||||
import { Apps } from '@rocket.chat/core-services';
|
||||
import type { LicenseModule } from '@rocket.chat/core-typings';
|
||||
import { License, type LicenseImp } from '@rocket.chat/license';
|
||||
|
||||
import { getInstallationSourceFromAppStorageItem } from '../../../../lib/apps/getInstallationSourceFromAppStorageItem';
|
||||
|
||||
type _canEnableAppDependencies = {
|
||||
Apps: typeof Apps;
|
||||
License: LicenseImp;
|
||||
};
|
||||
|
||||
export const _canEnableApp = async ({ Apps, License }: _canEnableAppDependencies, app: IAppStorageItem): Promise<void> => {
|
||||
if (!(await Apps.isInitialized())) {
|
||||
throw new Error('apps-engine-not-initialized');
|
||||
}
|
||||
|
||||
// Migrated apps were installed before the validation was implemented
|
||||
// so they're always allowed to be enabled
|
||||
if (app.migrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (app.info.addon && !License.hasModule(app.info.addon as LicenseModule)) {
|
||||
throw new Error('app-addon-not-valid');
|
||||
}
|
||||
|
||||
const source = getInstallationSourceFromAppStorageItem(app);
|
||||
switch (source) {
|
||||
case 'private':
|
||||
if (await License.shouldPreventAction('privateApps')) {
|
||||
throw new Error('license-prevented');
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
if (await License.shouldPreventAction('marketplaceApps')) {
|
||||
throw new Error('license-prevented');
|
||||
}
|
||||
|
||||
const marketplaceInfo = app.marketplaceInfo?.[0];
|
||||
if (marketplaceInfo?.isEnterpriseOnly && !License.hasValidLicense()) {
|
||||
throw new Error('invalid-license');
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export const canEnableApp = async (app: IAppStorageItem): Promise<void> => _canEnableApp({ Apps, License }, app);
|
||||
35
apps/meteor/ee/server/lib/license/enforceFipsLicense.ts
Normal file
35
apps/meteor/ee/server/lib/license/enforceFipsLicense.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { License } from '@rocket.chat/license';
|
||||
|
||||
import { showErrorBox } from '../../../../server/lib/logger/showBox';
|
||||
|
||||
export function enforceFipsLicense(): void {
|
||||
if (!crypto.getFips()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (License.hasModule('fips')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = !License.encryptedLicense
|
||||
? 'No license was found in this workspace'
|
||||
: (!License.hasValidLicense() && 'The license applied to this workspace is invalid or expired') ||
|
||||
"The license applied to this workspace does not include the 'fips' module";
|
||||
|
||||
showErrorBox(
|
||||
'FIPS LICENSE ERROR',
|
||||
[
|
||||
`${reason}.`,
|
||||
'',
|
||||
'This server is running in FIPS mode, which requires a valid license',
|
||||
"including the 'fips' module.",
|
||||
'',
|
||||
'Provide your license through the ROCKETCHAT_LICENSE environment variable,',
|
||||
'or contact Rocket.Chat to obtain one: https://www.rocket.chat/sales-contact',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
34
apps/meteor/ee/server/lib/license/getSeatsRequestLink.ts
Normal file
34
apps/meteor/ee/server/lib/license/getSeatsRequestLink.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { Settings, Users } from '@rocket.chat/models';
|
||||
|
||||
type WizardSettings = Array<ISetting>;
|
||||
|
||||
export const getSeatsRequestLink = async (url: string, params?: Record<string, string>): Promise<string> => {
|
||||
const workspaceId = await Settings.findOneById('Cloud_Workspace_Id');
|
||||
const activeUsers = await Users.getActiveLocalUserCount();
|
||||
const wizardSettings: WizardSettings = await Settings.findSetupWizardSettings().toArray();
|
||||
|
||||
const newUrl = new URL(url);
|
||||
|
||||
if (workspaceId?.value) {
|
||||
newUrl.searchParams.append('workspaceId', String(workspaceId.value));
|
||||
}
|
||||
|
||||
if (activeUsers) {
|
||||
newUrl.searchParams.append('activeUsers', String(activeUsers));
|
||||
}
|
||||
|
||||
wizardSettings
|
||||
.filter(({ _id, value }) => ['Industry', 'Country', 'Size'].includes(_id) && value)
|
||||
.forEach((setting) => {
|
||||
newUrl.searchParams.append(setting._id.toLowerCase(), String(setting.value));
|
||||
});
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
newUrl.searchParams.append(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
return newUrl.toString();
|
||||
};
|
||||
21
apps/meteor/ee/server/lib/license/lib/getAppCount.ts
Normal file
21
apps/meteor/ee/server/lib/license/lib/getAppCount.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Apps } from '@rocket.chat/core-services';
|
||||
import type { LicenseAppSources } from '@rocket.chat/core-typings';
|
||||
|
||||
import { getInstallationSourceFromAppStorageItem } from '../../../../../lib/apps/getInstallationSourceFromAppStorageItem';
|
||||
|
||||
export async function getAppCount(source: LicenseAppSources): Promise<number> {
|
||||
if (!(await Apps.isInitialized())) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const apps = await Apps.getApps({ enabled: true });
|
||||
|
||||
if (!apps || !Array.isArray(apps)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const storageItems = await Promise.all(apps.map((app) => Apps.getAppStorageItemById(app.id)));
|
||||
const activeAppsFromSameSource = storageItems.filter((item) => item && getInstallationSourceFromAppStorageItem(item) === source);
|
||||
|
||||
return activeAppsFromSameSource.length;
|
||||
}
|
||||
57
apps/meteor/ee/server/lib/license/license.internalService.ts
Normal file
57
apps/meteor/ee/server/lib/license/license.internalService.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ILicense } from '@rocket.chat/core-services';
|
||||
import { api, ServiceClassInternal } from '@rocket.chat/core-services';
|
||||
import type { LicenseModule } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
|
||||
import { guestPermissions } from '../authorization/guestPermissions';
|
||||
import { resetEnterprisePermissions } from '../authorization/resetEnterprisePermissions';
|
||||
|
||||
export class LicenseService extends ServiceClassInternal implements ILicense {
|
||||
protected name = 'license';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
License.onValidateLicense((): void => {
|
||||
if (!License.hasValidLicense()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void api.broadcast('authorization.guestPermissions', guestPermissions);
|
||||
void resetEnterprisePermissions();
|
||||
});
|
||||
|
||||
License.onModule((licenseModule) => {
|
||||
void api.broadcast('license.module', licenseModule);
|
||||
});
|
||||
|
||||
this.onEvent('license.actions', (preventedActions) => License.syncShouldPreventActionResults(preventedActions));
|
||||
|
||||
this.onEvent('license.sync', () => License.sync());
|
||||
}
|
||||
|
||||
override async started(): Promise<void> {
|
||||
if (!License.hasValidLicense()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void api.broadcast('authorization.guestPermissions', guestPermissions);
|
||||
await resetEnterprisePermissions();
|
||||
}
|
||||
|
||||
hasModule(feature: LicenseModule): boolean {
|
||||
return License.hasModule(feature);
|
||||
}
|
||||
|
||||
hasValidLicense(): boolean {
|
||||
return License.hasValidLicense();
|
||||
}
|
||||
|
||||
getModules(): string[] {
|
||||
return License.getModules();
|
||||
}
|
||||
|
||||
getGuestPermissions(): string[] {
|
||||
return guestPermissions;
|
||||
}
|
||||
}
|
||||
28
apps/meteor/ee/server/lib/license/settings.ts
Normal file
28
apps/meteor/ee/server/lib/license/settings.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { settingsRegistry } from '../../../../server/settings';
|
||||
|
||||
// The proper name for this group is Premium, but we can't change it because it's already in use and we will break the settings
|
||||
// TODO: Keep this until next major updates
|
||||
await settingsRegistry.addGroup('Enterprise', async function () {
|
||||
await this.section('Enterprise', async function () {
|
||||
await this.add('Enterprise_License', '', {
|
||||
type: 'string',
|
||||
i18nLabel: 'Premium_License',
|
||||
alert: 'Premium_License_alert',
|
||||
});
|
||||
await this.add('Enterprise_License_Data', '', {
|
||||
type: 'string',
|
||||
hidden: true,
|
||||
public: false,
|
||||
});
|
||||
await this.add('Enterprise_License_Status', '', {
|
||||
readonly: true,
|
||||
type: 'string',
|
||||
i18nLabel: 'Status',
|
||||
});
|
||||
});
|
||||
await this.add('Cloud_Workspace_AirGapped_Restrictions_Remaining_Days', -1, {
|
||||
type: 'int',
|
||||
readonly: true,
|
||||
public: true,
|
||||
});
|
||||
});
|
||||
180
apps/meteor/ee/server/lib/license/startup.ts
Normal file
180
apps/meteor/ee/server/lib/license/startup.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { LicenseLimitKind } from '@rocket.chat/core-typings';
|
||||
import { applyLicense, applyLicenseOrRemove, applyNewestLicense, License } from '@rocket.chat/license';
|
||||
import { Subscriptions, Users, Settings, LivechatContacts } from '@rocket.chat/models';
|
||||
import { wrapExceptions } from '@rocket.chat/tools';
|
||||
import moment from 'moment';
|
||||
|
||||
import { getAppCount } from './lib/getAppCount';
|
||||
import { callbacks } from '../../../../server/lib/callbacks';
|
||||
import { syncWorkspace } from '../../../../server/lib/cloud/syncWorkspace';
|
||||
import { SystemLogger } from '../../../../server/lib/logger/system';
|
||||
import { notifyOnSettingChangedById } from '../../../../server/lib/notifyListener';
|
||||
import { settings } from '../../../../server/settings';
|
||||
|
||||
const applyStartupLicense = async (): Promise<void> => {
|
||||
// Licenses can't be validated before the workspace URL is known; set it explicitly
|
||||
// so the startup license is never left pending behind the Site_Url watcher.
|
||||
await License.setWorkspaceUrl(settings.get<string>('Site_Url'));
|
||||
|
||||
await applyNewestLicense(License, settings.get<string>('Enterprise_License') ?? '', process.env.ROCKETCHAT_LICENSE ?? '');
|
||||
};
|
||||
|
||||
export const startLicense = async () => {
|
||||
settings.watch<string>('Site_Url', (value) => {
|
||||
if (value) {
|
||||
void License.setWorkspaceUrl(value);
|
||||
}
|
||||
});
|
||||
|
||||
License.onValidateLicense(async () => {
|
||||
(await Settings.updateValueById('Enterprise_License', License.encryptedLicense)).modifiedCount &&
|
||||
void notifyOnSettingChangedById('Enterprise_License');
|
||||
|
||||
(await Settings.updateValueById('Enterprise_License_Status', 'Valid')).modifiedCount &&
|
||||
void notifyOnSettingChangedById('Enterprise_License_Status');
|
||||
});
|
||||
|
||||
License.onInvalidateLicense(async () => {
|
||||
(await Settings.updateValueById('Enterprise_License_Status', 'Invalid')).modifiedCount &&
|
||||
void notifyOnSettingChangedById('Enterprise_License_Status');
|
||||
});
|
||||
|
||||
License.onRemoveLicense(async () => {
|
||||
(await Settings.updateValueById('Enterprise_License', '')).modifiedCount &&
|
||||
void notifyOnSettingChangedById('Enterprise_License_Status');
|
||||
|
||||
(await Settings.updateValueById('Enterprise_License_Status', 'Invalid')).modifiedCount &&
|
||||
void notifyOnSettingChangedById('Enterprise_License_Status');
|
||||
});
|
||||
|
||||
/**
|
||||
* This is a debounced function that will sync the workspace data to the cloud.
|
||||
* it caches the context, waits for a second and then syncs the data.
|
||||
*/
|
||||
|
||||
const syncByTriggerDebounced = (() => {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const contexts: Set<string> = new Set();
|
||||
return async (context: string) => {
|
||||
contexts.add(context);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
timeout = undefined;
|
||||
void syncByTrigger([...contexts]);
|
||||
contexts.clear();
|
||||
}, 1000);
|
||||
};
|
||||
})();
|
||||
|
||||
const syncByTrigger = async (contexts: string[]) => {
|
||||
if (!License.encryptedLicense) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingData = wrapExceptions(() => JSON.parse(settings.get<string>('Enterprise_License_Data'))).catch(() => ({})) ?? {};
|
||||
|
||||
const date = new Date();
|
||||
|
||||
const day = date.getDate();
|
||||
const month = date.getMonth() + 1;
|
||||
const year = date.getFullYear();
|
||||
|
||||
const period = `${year}-${month}-${day}`;
|
||||
|
||||
const [, , signed] = License.encryptedLicense.split('.');
|
||||
|
||||
// Check if this sync has already been done. Based on License, behavior.
|
||||
|
||||
if ([...contexts.values()].every((context) => existingData.signed === signed && existingData[context] === period)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = Object.fromEntries(contexts.map((context) => [context, period]));
|
||||
|
||||
(
|
||||
await Settings.updateValueById(
|
||||
'Enterprise_License_Data',
|
||||
JSON.stringify({
|
||||
...(existingData.signed === signed && existingData),
|
||||
...existingData,
|
||||
...obj,
|
||||
signed,
|
||||
}),
|
||||
)
|
||||
).modifiedCount && void notifyOnSettingChangedById('Enterprise_License_Data');
|
||||
|
||||
try {
|
||||
await syncWorkspace();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
License.setLicenseLimitCounter('activeUsers', () => Users.getActiveLocalUserCount());
|
||||
License.setLicenseLimitCounter('guestUsers', () => Users.getActiveLocalGuestCount());
|
||||
License.setLicenseLimitCounter('roomsPerGuest', async (context) =>
|
||||
context?.userId ? Subscriptions.countByUserIdExceptType(context.userId, 'd') : 0,
|
||||
);
|
||||
License.setLicenseLimitCounter('privateApps', () => getAppCount('private'));
|
||||
License.setLicenseLimitCounter('marketplaceApps', () => getAppCount('marketplace'));
|
||||
License.setLicenseLimitCounter('monthlyActiveContacts', () => LivechatContacts.countContactsOnPeriod(moment.utc().format('YYYY-MM')));
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
// When settings are loaded, apply the current license if there is one.
|
||||
settings.onReady(async () => {
|
||||
import('./airGappedRestrictions');
|
||||
await applyStartupLicense();
|
||||
|
||||
License.onInstall(() => {
|
||||
if (License.hasOfflineLicense()) {
|
||||
// startup level so it is visible at the default Log_Level, like 'License installed'
|
||||
SystemLogger.startup(
|
||||
'Offline license detected: outbound connections to Rocket.Chat Cloud services and the Rocket.Chat Push Gateway are disabled',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// After the current license is already loaded, watch the setting value to react to new licenses being applied.
|
||||
settings.change<string>('Enterprise_License', (license) => applyLicenseOrRemove(license, true));
|
||||
|
||||
callbacks.add('workspaceLicenseRemoved', () => License.remove());
|
||||
|
||||
callbacks.add('workspaceLicenseChanged', (updatedLicense) => applyLicense(updatedLicense, true));
|
||||
|
||||
License.onInstall(async () => void api.broadcast('license.actions', {} as Record<Partial<LicenseLimitKind>, boolean>));
|
||||
|
||||
License.onInvalidate(async () => void api.broadcast('license.actions', {} as Record<Partial<LicenseLimitKind>, boolean>));
|
||||
|
||||
License.onBehaviorTriggered('prevent_action', (context) => syncByTriggerDebounced(`prevent_action_${context.limit}`));
|
||||
|
||||
License.onBehaviorTriggered('start_fair_policy', async (context) => syncByTriggerDebounced(`start_fair_policy_${context.limit}`));
|
||||
|
||||
License.onBehaviorTriggered('disable_modules', async (context) => syncByTriggerDebounced(`disable_modules_${context.limit}`));
|
||||
|
||||
License.onChange(() => api.broadcast('license.sync'));
|
||||
|
||||
License.onBehaviorToggled('prevent_action', (context) => {
|
||||
if (!context.limit) {
|
||||
return;
|
||||
}
|
||||
void api.broadcast('license.actions', {
|
||||
[context.limit]: true,
|
||||
} as Record<Partial<LicenseLimitKind>, boolean>);
|
||||
});
|
||||
|
||||
License.onBehaviorToggled('allow_action', (context) => {
|
||||
if (!context.limit) {
|
||||
return;
|
||||
}
|
||||
void api.broadcast('license.actions', {
|
||||
[context.limit]: false,
|
||||
} as Record<Partial<LicenseLimitKind>, boolean>);
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
178
apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts
Normal file
178
apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { IMessage, IRoom, IUser, IReadReceipt, IReadReceiptWithUser } from '@rocket.chat/core-typings';
|
||||
import { LivechatVisitors, ReadReceipts, ReadReceiptsArchive, Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models';
|
||||
|
||||
import { SystemLogger } from '../../../../server/lib/logger/system';
|
||||
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../../../server/lib/notifyListener';
|
||||
import { settings } from '../../../../server/settings';
|
||||
|
||||
// debounced function by roomId, so multiple calls within 2 seconds to same roomId runs only once
|
||||
const list: Record<string, NodeJS.Timeout> = {};
|
||||
const debounceByRoomId = function <T extends Pick<IRoom, '_id'>>(fn: (room: T) => Promise<void>) {
|
||||
return function (this: unknown, room: T) {
|
||||
clearTimeout(list[room._id]);
|
||||
list[room._id] = setTimeout(() => {
|
||||
void fn.call(this, room);
|
||||
delete list[room._id];
|
||||
}, 2000);
|
||||
};
|
||||
};
|
||||
|
||||
const updateMessages = debounceByRoomId(async ({ _id, lm }: Pick<IRoom, '_id' | 'lm'>) => {
|
||||
// @TODO maybe store firstSubscription in room object so we don't need to call the above update method
|
||||
const firstSubscription = await Subscriptions.getMinimumLastSeenByRoomId(_id);
|
||||
if (!firstSubscription?.ls) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Messages.setVisibleMessagesAsRead(_id, firstSubscription.ls);
|
||||
if (result.modifiedCount > 0) {
|
||||
void api.broadcast('notify.messagesRead', { rid: _id, until: firstSubscription.ls });
|
||||
}
|
||||
|
||||
if (lm && lm <= firstSubscription.ls) {
|
||||
await Rooms.setLastMessageAsRead(_id);
|
||||
void notifyOnRoomChangedById(_id);
|
||||
}
|
||||
});
|
||||
|
||||
class ReadReceiptClass {
|
||||
async markMessagesAsRead(roomId: string, userId: string, userLastSeen: Date) {
|
||||
if (!settings.get('Message_Read_Receipt_Enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = await Rooms.findOneById(roomId, { projection: { lm: 1 } });
|
||||
|
||||
// if users last seen is greater than room's last message, it means the user already have this room marked as read
|
||||
if (!room || (room.lm && userLastSeen > room.lm)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.storeReadReceipts(
|
||||
() => {
|
||||
return Messages.findVisibleUnreadMessagesByRoomAndDate(roomId, userLastSeen).toArray();
|
||||
},
|
||||
roomId,
|
||||
userId,
|
||||
);
|
||||
|
||||
updateMessages(room);
|
||||
}
|
||||
|
||||
async markMessageAsReadBySender(message: IMessage, { _id: roomId }: { _id: string }, userId: string) {
|
||||
if (!settings.get('Message_Read_Receipt_Enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.unread) {
|
||||
return;
|
||||
}
|
||||
|
||||
// mark message as read if the sender is the only one in the room
|
||||
const isUserAlone = (await Subscriptions.countUnarchivedByRoomIdAndNotUserId(roomId, userId)) === 0;
|
||||
if (isUserAlone) {
|
||||
const result = await Messages.setAsReadById(message._id);
|
||||
if (result.modifiedCount > 0) {
|
||||
void notifyOnMessageChange({
|
||||
id: message._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.storeReadReceipts(
|
||||
() => {
|
||||
return Promise.resolve([message]);
|
||||
},
|
||||
roomId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
async storeThreadMessagesReadReceipts(tmid: string, userId: string, userLastSeen: Date) {
|
||||
if (!settings.get('Message_Read_Receipt_Enabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await Messages.findOneById(tmid, { projection: { tlm: 1, rid: 1 } });
|
||||
|
||||
// if users last seen is greater than thread's last message, it means the user has already marked this thread as read
|
||||
if (!message || (message.tlm && userLastSeen > message.tlm)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.storeReadReceipts(
|
||||
() => {
|
||||
return Messages.findUnreadThreadMessagesByDate(message.rid, tmid, userId, userLastSeen).toArray();
|
||||
},
|
||||
message.rid,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
private async storeReadReceipts(
|
||||
getMessages: () => Promise<Pick<IMessage, '_id' | 't' | 'pinned' | 'drid' | 'tmid'>[]>,
|
||||
roomId: string,
|
||||
userId: string,
|
||||
) {
|
||||
if (settings.get('Message_Read_Receipt_Store_Users')) {
|
||||
const ts = new Date();
|
||||
const receipts = (await getMessages()).map((message) => ({
|
||||
_id: message._id + userId,
|
||||
roomId,
|
||||
userId,
|
||||
messageId: message._id,
|
||||
ts,
|
||||
}));
|
||||
|
||||
if (receipts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ReadReceipts.insertMany(receipts);
|
||||
} catch (err) {
|
||||
SystemLogger.error({ msg: 'Error inserting read receipts per user', err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getReceipts(message: Pick<IMessage, '_id' | 'receiptsArchived'>): Promise<IReadReceiptWithUser[]> {
|
||||
// Query hot storage (always)
|
||||
const hotReceipts = await ReadReceipts.findByMessageId(message._id).toArray();
|
||||
|
||||
// Query cold storage only if message has archived receipts
|
||||
let coldReceipts: IReadReceipt[] = [];
|
||||
if (message.receiptsArchived) {
|
||||
coldReceipts = await ReadReceiptsArchive.findByMessageId(message._id).toArray();
|
||||
}
|
||||
|
||||
// Combine receipts from both storages
|
||||
const receipts = [...new Map([...hotReceipts, ...coldReceipts].map((receipt) => [receipt._id, receipt])).values()];
|
||||
|
||||
// get unique receipts user ids
|
||||
const userIds = [...new Set(receipts.map((receipt) => receipt.userId))];
|
||||
|
||||
// get users for the receipts
|
||||
const users = await Users.findByIds(userIds, { projection: { username: 1, name: 1 } }).toArray();
|
||||
const usersMap: Map<string, Pick<IUser, '_id' | 'username' | 'name'>> = new Map(users.map((user) => [user._id, user]));
|
||||
|
||||
// Resolve livechat visitors for any unresolved user IDs
|
||||
const unresolvedIds = userIds.filter((id) => !usersMap.has(id));
|
||||
if (unresolvedIds.length > 0) {
|
||||
const visitors = await LivechatVisitors.findByIds(unresolvedIds, { projection: { username: 1, name: 1 } }).toArray();
|
||||
for (const visitor of visitors) {
|
||||
usersMap.set(visitor._id, { _id: visitor._id, username: visitor.username, name: visitor.name });
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
receipts.map(async (receipt) => ({
|
||||
...receipt,
|
||||
user: usersMap.get(receipt.userId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ReadReceipt = new ReadReceiptClass();
|
||||
7
apps/meteor/ee/server/lib/message-read-receipt/index.ts
Normal file
7
apps/meteor/ee/server/lib/message-read-receipt/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { License } from '@rocket.chat/license';
|
||||
|
||||
await License.onLicense('message-read-receipt', async () => {
|
||||
await import('../../hooks/messages/afterReadMessages');
|
||||
await import('../../hooks/messages/afterSaveMessage');
|
||||
await import('../../hooks/messages/afterDeleteRoom');
|
||||
});
|
||||
97
apps/meteor/ee/server/lib/oauth/Manager.ts
Normal file
97
apps/meteor/ee/server/lib/oauth/Manager.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { Roles, Rooms, Users } from '@rocket.chat/models';
|
||||
|
||||
import { addUserToRoom } from '../../../../server/lib/rooms/addUserToRoom';
|
||||
import { createRoom } from '../../../../server/lib/rooms/createRoom';
|
||||
import { getValidRoomName } from '../../../../server/lib/utils/lib/getValidRoomName';
|
||||
import { syncUserRoles } from '../syncUserRoles';
|
||||
|
||||
const logger = new Logger('OAuth');
|
||||
|
||||
export class OAuthEEManager {
|
||||
static async mapSSOGroupsToChannels(
|
||||
user: IUser,
|
||||
identity: Record<string, any>,
|
||||
groupClaimName: string,
|
||||
channelsMap: Record<string, any> | undefined,
|
||||
channelsAdmin: string,
|
||||
): Promise<void> {
|
||||
if (channelsMap && user && identity && groupClaimName) {
|
||||
const groupsFromSSO = identity[groupClaimName] || [];
|
||||
|
||||
const userChannelAdmin = await Users.findOneByUsernameIgnoringCase(channelsAdmin);
|
||||
if (!userChannelAdmin) {
|
||||
logger.error({ msg: 'could not create channel, user not found', channelsAdmin });
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const ssoGroup of Object.keys(channelsMap)) {
|
||||
if (typeof ssoGroup === 'string') {
|
||||
let channels = channelsMap[ssoGroup];
|
||||
if (!Array.isArray(channels)) {
|
||||
channels = [channels];
|
||||
}
|
||||
for await (const channel of channels) {
|
||||
const name = await getValidRoomName(channel.trim(), undefined, { allowDuplicates: true });
|
||||
let room = await Rooms.findOneByNonValidatedName(name);
|
||||
if (!room) {
|
||||
const createdRoom = await createRoom('c', channel, userChannelAdmin, [], false, false);
|
||||
if (!createdRoom?.rid) {
|
||||
logger.error({ msg: 'could not create channel', channel });
|
||||
return;
|
||||
}
|
||||
|
||||
room = createdRoom;
|
||||
}
|
||||
|
||||
if (room && Array.isArray(groupsFromSSO) && groupsFromSSO.includes(ssoGroup)) {
|
||||
await addUserToRoom(room._id, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async updateRolesFromSSO(
|
||||
user: Record<string, any>,
|
||||
identity: Record<string, any>,
|
||||
roleClaimName: string,
|
||||
rolesToSync: string[],
|
||||
): Promise<void> {
|
||||
if (user && identity && roleClaimName) {
|
||||
const rolesFromSSO = await this.mapRolesFromSSO(identity, roleClaimName);
|
||||
|
||||
if (!Array.isArray(user.roles)) {
|
||||
user.roles = [];
|
||||
}
|
||||
|
||||
const rolesIdsFromSSO = (await Roles.findInIdsOrNames(rolesFromSSO).toArray()).map((role) => role._id);
|
||||
const allowedRoles = (await Roles.findInIdsOrNames(rolesToSync).toArray()).map((role) => role._id);
|
||||
|
||||
await syncUserRoles(user._id, rolesIdsFromSSO, {
|
||||
allowedRoles,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Returns list of roles from SSO identity
|
||||
static async mapRolesFromSSO(identity: Record<string, any>, roleClaimName: string): Promise<string[]> {
|
||||
if (!identity || !roleClaimName || !identity[roleClaimName] || !Array.isArray(identity[roleClaimName])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const baseRoles = identity[roleClaimName] as string[];
|
||||
|
||||
const filteredRoles = baseRoles.filter((val) => val !== 'offline_access' && val !== 'uma_authorization');
|
||||
const validRoleList = [];
|
||||
for await (const role of filteredRoles) {
|
||||
if (await Roles.findOneByIdOrName(role)) {
|
||||
validRoleList.push(role);
|
||||
}
|
||||
}
|
||||
|
||||
return validRoleList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Agenda } from '@rocket.chat/agenda';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import type { MainLogger } from '@rocket.chat/logger';
|
||||
import { LivechatRooms, Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { MongoInternals } from 'meteor/mongo';
|
||||
import moment from 'moment';
|
||||
|
||||
import { schedulerLogger } from './logger';
|
||||
import { closeRoom } from '../../../../server/lib/omnichannel/closeRoom';
|
||||
|
||||
const SCHEDULER_NAME = 'omnichannel_auto_close_on_hold_scheduler';
|
||||
|
||||
export class AutoCloseOnHoldSchedulerClass {
|
||||
scheduler: Agenda;
|
||||
|
||||
schedulerUser: IUser;
|
||||
|
||||
running: boolean;
|
||||
|
||||
logger: MainLogger;
|
||||
|
||||
constructor() {
|
||||
this.logger = schedulerLogger.section('AutoCloseOnHoldScheduler');
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduler = new Agenda({
|
||||
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
|
||||
db: { collection: SCHEDULER_NAME },
|
||||
defaultConcurrency: 1,
|
||||
processEvery: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? '3 seconds' : '1 minute',
|
||||
});
|
||||
|
||||
await this.scheduler.start();
|
||||
this.running = true;
|
||||
this.logger.info('Service started');
|
||||
}
|
||||
|
||||
public async scheduleRoom(roomId: string, timeout: number, comment: string): Promise<void> {
|
||||
if (!this.running) {
|
||||
throw new Error('AutoCloseOnHoldScheduler is not running');
|
||||
}
|
||||
|
||||
this.logger.debug({ msg: 'Scheduling room to be closed', roomId, timeoutSeconds: timeout });
|
||||
await this.unscheduleRoom(roomId);
|
||||
|
||||
const jobName = `${SCHEDULER_NAME}-${roomId}`;
|
||||
const when = moment(new Date()).add(timeout, 's').toDate();
|
||||
|
||||
this.scheduler.define(jobName, this.executeJob.bind(this));
|
||||
await this.scheduler.schedule(when, jobName, { roomId, comment });
|
||||
}
|
||||
|
||||
public async unscheduleRoom(roomId: string): Promise<void> {
|
||||
if (!this.running) {
|
||||
throw new Error('AutoCloseOnHoldScheduler is not running');
|
||||
}
|
||||
this.logger.debug({ msg: 'Unscheduling room', roomId });
|
||||
const jobName = `${SCHEDULER_NAME}-${roomId}`;
|
||||
await this.scheduler.cancel({ name: jobName });
|
||||
}
|
||||
|
||||
private async executeJob({ attrs: { data } }: any = {}): Promise<void> {
|
||||
this.logger.debug({ msg: 'Executing job for room', roomId: data.roomId });
|
||||
const { roomId, comment } = data;
|
||||
|
||||
const [room, user] = await Promise.all([LivechatRooms.findOneById(roomId), this.getSchedulerUser()]);
|
||||
if (!room || !user) {
|
||||
throw new Error(
|
||||
`Unable to process AutoCloseOnHoldScheduler job because room or user not found for roomId: ${roomId} and userId: rocket.cat`,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
room,
|
||||
user,
|
||||
comment,
|
||||
};
|
||||
|
||||
await closeRoom(payload);
|
||||
}
|
||||
|
||||
private async getSchedulerUser(): Promise<IUser> {
|
||||
if (!this.schedulerUser) {
|
||||
const schedulerUser = await Users.findOneById('rocket.cat');
|
||||
if (!schedulerUser) {
|
||||
throw new Error('Scheduler user not found');
|
||||
}
|
||||
this.schedulerUser = schedulerUser;
|
||||
}
|
||||
|
||||
return this.schedulerUser;
|
||||
}
|
||||
}
|
||||
|
||||
export const AutoCloseOnHoldScheduler = new AutoCloseOnHoldSchedulerClass();
|
||||
|
||||
Meteor.startup(() => {
|
||||
void AutoCloseOnHoldScheduler.init();
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Agenda } from '@rocket.chat/agenda';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import type { MainLogger } from '@rocket.chat/logger';
|
||||
import { LivechatRooms, Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { MongoInternals } from 'meteor/mongo';
|
||||
|
||||
import { schedulerLogger } from './logger';
|
||||
import { forwardRoomToAgent } from '../../../../server/lib/omnichannel/Helper';
|
||||
import { RoutingManager } from '../../../../server/lib/omnichannel/RoutingManager';
|
||||
import { returnRoomAsInquiry } from '../../../../server/lib/omnichannel/rooms';
|
||||
import { settings } from '../../../../server/settings';
|
||||
|
||||
const SCHEDULER_NAME = 'omnichannel_scheduler';
|
||||
|
||||
export class AutoTransferChatSchedulerClass {
|
||||
scheduler: Agenda;
|
||||
|
||||
running: boolean;
|
||||
|
||||
user: IUser;
|
||||
|
||||
logger: MainLogger;
|
||||
|
||||
constructor() {
|
||||
this.logger = schedulerLogger.section('AutoTransferChatScheduler');
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduler = new Agenda({
|
||||
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
|
||||
db: { collection: SCHEDULER_NAME },
|
||||
defaultConcurrency: 1,
|
||||
processEvery: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? '3 seconds' : '1 minute',
|
||||
});
|
||||
|
||||
await this.scheduler.start();
|
||||
this.running = true;
|
||||
this.logger.info('Service started');
|
||||
}
|
||||
|
||||
private async getSchedulerUser(): Promise<IUser & { userType: 'user' }> {
|
||||
const user = await Users.findOneById('rocket.cat');
|
||||
if (!user) {
|
||||
this.logger.error('Error while transferring room: user not found');
|
||||
throw new Error('error-no-cat');
|
||||
}
|
||||
return {
|
||||
...user,
|
||||
userType: 'user',
|
||||
};
|
||||
}
|
||||
|
||||
public async scheduleRoom(roomId: string, timeout: number): Promise<void> {
|
||||
this.logger.debug({ msg: 'Scheduling room to be transferred', roomId, timeoutSeconds: timeout });
|
||||
await this.unscheduleRoom(roomId);
|
||||
|
||||
const jobName = `${SCHEDULER_NAME}-${roomId}`;
|
||||
const when = new Date();
|
||||
when.setSeconds(when.getSeconds() + timeout);
|
||||
|
||||
this.scheduler.define(jobName, this.executeJob.bind(this));
|
||||
await this.scheduler.schedule(when, jobName, { roomId });
|
||||
await LivechatRooms.setAutoTransferOngoingById(roomId);
|
||||
}
|
||||
|
||||
public async unscheduleRoom(roomId: string): Promise<void> {
|
||||
this.logger.debug({ msg: 'Unscheduling room', roomId });
|
||||
const jobName = `${SCHEDULER_NAME}-${roomId}`;
|
||||
|
||||
await LivechatRooms.unsetAutoTransferOngoingById(roomId);
|
||||
await this.scheduler.cancel({ name: jobName });
|
||||
}
|
||||
|
||||
private async transferRoom(roomId: string): Promise<void> {
|
||||
this.logger.debug({ msg: 'Transferring room', roomId });
|
||||
const room = await LivechatRooms.findOneById(roomId);
|
||||
if (!room?.open || !room?.servedBy?._id) {
|
||||
throw new Error('Room is not open or is not being served by an agent');
|
||||
}
|
||||
|
||||
const {
|
||||
departmentId,
|
||||
servedBy: { _id: ignoreAgentId },
|
||||
} = room;
|
||||
|
||||
const timeoutDuration = settings.get<number>('Livechat_auto_transfer_chat_timeout').toString();
|
||||
|
||||
if (!RoutingManager.getConfig()?.autoAssignAgent) {
|
||||
this.logger.debug({ msg: 'Auto-assign agent is disabled, returning room as inquiry', roomId });
|
||||
|
||||
await returnRoomAsInquiry(room, departmentId, {
|
||||
scope: 'autoTransferUnansweredChatsToQueue',
|
||||
comment: timeoutDuration,
|
||||
transferredBy: await this.getSchedulerUser(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = await RoutingManager.getNextAgent(departmentId, ignoreAgentId);
|
||||
if (!agent) {
|
||||
this.logger.error({
|
||||
msg: 'No agent found to transfer unanswered room',
|
||||
roomId: room._id,
|
||||
timeoutSeconds: timeoutDuration,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug({ msg: 'Transferring room to agent', roomId, agentId: agent.agentId });
|
||||
|
||||
const transferredBy = await this.getSchedulerUser();
|
||||
|
||||
await forwardRoomToAgent(room, {
|
||||
userId: agent.agentId,
|
||||
transferredBy,
|
||||
transferredTo: agent,
|
||||
scope: 'autoTransferUnansweredChatsToAgent',
|
||||
comment: timeoutDuration,
|
||||
});
|
||||
}
|
||||
|
||||
private async executeJob({ attrs: { data } }: any = {}): Promise<void> {
|
||||
const { roomId } = data;
|
||||
|
||||
try {
|
||||
await this.transferRoom(roomId);
|
||||
|
||||
await Promise.all([LivechatRooms.setAutoTransferredAtById(roomId), this.unscheduleRoom(roomId)]);
|
||||
} catch (error) {
|
||||
this.logger.error({ msg: 'Error while executing auto-transfer job', schedulerName: SCHEDULER_NAME, roomId, err: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const AutoTransferChatScheduler = new AutoTransferChatSchedulerClass();
|
||||
|
||||
Meteor.startup(() => {
|
||||
void AutoTransferChatScheduler.init();
|
||||
});
|
||||
49
apps/meteor/ee/server/lib/omnichannel/Department.ts
Normal file
49
apps/meteor/ee/server/lib/omnichannel/Department.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartment } from '@rocket.chat/models';
|
||||
import { applyDepartmentRestrictions } from '@rocket.chat/omni-core';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import type { Filter } from 'mongodb';
|
||||
|
||||
export const findAllDepartmentsAvailable = async (
|
||||
uid: string,
|
||||
unitId: string,
|
||||
offset: number,
|
||||
count: number,
|
||||
text?: string,
|
||||
onlyMyDepartments = false,
|
||||
): Promise<{ departments: ILivechatDepartment[]; total: number }> => {
|
||||
const filterReg = new RegExp(escapeRegExp(text || ''), 'i');
|
||||
|
||||
let query: Filter<ILivechatDepartment> = {
|
||||
type: { $ne: 'u' },
|
||||
$or: [{ ancestors: { $in: [[unitId], undefined, []] } }, { ancestors: { $exists: false } }],
|
||||
...(text && { name: filterReg }),
|
||||
};
|
||||
|
||||
if (onlyMyDepartments) {
|
||||
query = await applyDepartmentRestrictions(query, uid);
|
||||
}
|
||||
|
||||
const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { limit: count, skip: offset, sort: { name: 1 } });
|
||||
|
||||
const [departments, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return { departments, total };
|
||||
};
|
||||
|
||||
export const findAllDepartmentsByUnit = async (
|
||||
unitId: string,
|
||||
offset: number,
|
||||
count: number,
|
||||
): Promise<{ departments: ILivechatDepartment[]; total: number }> => {
|
||||
const { cursor, totalCount } = LivechatDepartment.findPaginated(
|
||||
{
|
||||
ancestors: { $in: [unitId] },
|
||||
},
|
||||
{ limit: count, skip: offset, sort: { name: 1, _id: 1 } },
|
||||
);
|
||||
|
||||
const [departments, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
return { departments, total };
|
||||
};
|
||||
376
apps/meteor/ee/server/lib/omnichannel/Helper.ts
Normal file
376
apps/meteor/ee/server/lib/omnichannel/Helper.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type {
|
||||
ILivechatDepartment,
|
||||
IOmnichannelRoom,
|
||||
IOmnichannelServiceLevelAgreements,
|
||||
InquiryWithAgentInfo,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import type { Updater } from '@rocket.chat/models';
|
||||
import {
|
||||
Rooms as RoomRaw,
|
||||
LivechatRooms,
|
||||
LivechatDepartment as LivechatDepartmentRaw,
|
||||
LivechatCustomField,
|
||||
LivechatInquiry,
|
||||
Users,
|
||||
} from '@rocket.chat/models';
|
||||
import moment from 'moment';
|
||||
import type { Document } from 'mongodb';
|
||||
|
||||
import { OmnichannelQueueInactivityMonitor } from './QueueInactivityMonitor';
|
||||
import { updateInquiryQueueSla } from './SlaHelper';
|
||||
import { memoizeDebounce } from './debounceByParams';
|
||||
import { logger } from './logger';
|
||||
import { getOmniChatSortQuery } from '../../../../app/livechat/lib/inquiries';
|
||||
import { callbacks } from '../../../../server/lib/callbacks';
|
||||
import { getInquirySortMechanismSetting } from '../../../../server/lib/omnichannel/settings';
|
||||
import { settings } from '../../../../server/settings';
|
||||
|
||||
type QueueInfo = {
|
||||
message: {
|
||||
text: any;
|
||||
user: {
|
||||
_id: string;
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
statistics: Document;
|
||||
numberMostRecentChats: number;
|
||||
};
|
||||
|
||||
export const isAgentWithinChatLimits = async ({
|
||||
agentId,
|
||||
departmentId,
|
||||
totalChats,
|
||||
departmentChats,
|
||||
}: {
|
||||
agentId: string;
|
||||
departmentId?: string;
|
||||
totalChats: number;
|
||||
departmentChats: number;
|
||||
}): Promise<boolean> => {
|
||||
let agentLimit = 0;
|
||||
let globalLimit = 0;
|
||||
|
||||
const user = await Users.getAgentInfo(agentId, settings.get('Livechat_show_agent_info'));
|
||||
const rawAgentLimit = user?.livechat?.maxNumberSimultaneousChat;
|
||||
|
||||
if (rawAgentLimit !== undefined && rawAgentLimit !== null) {
|
||||
const numericAgentLimit = Number(rawAgentLimit);
|
||||
if (numericAgentLimit > 0) {
|
||||
agentLimit = numericAgentLimit;
|
||||
}
|
||||
}
|
||||
|
||||
if (agentLimit === 0) {
|
||||
const settingLimit = settings.get<number>('Livechat_maximum_chats_per_agent');
|
||||
if (settingLimit > 0) {
|
||||
globalLimit = settingLimit;
|
||||
}
|
||||
}
|
||||
|
||||
if (departmentId) {
|
||||
const department = await LivechatDepartmentRaw.findOneById<Pick<ILivechatDepartment, 'maxNumberSimultaneousChat'>>(departmentId, {
|
||||
projection: { maxNumberSimultaneousChat: 1 },
|
||||
});
|
||||
let departmentLimit = 0;
|
||||
|
||||
if (department?.maxNumberSimultaneousChat !== undefined && department.maxNumberSimultaneousChat !== null) {
|
||||
const numericDeptLimit = Number(department.maxNumberSimultaneousChat);
|
||||
if (numericDeptLimit > 0) {
|
||||
departmentLimit = numericDeptLimit;
|
||||
}
|
||||
}
|
||||
|
||||
if (departmentLimit > 0) {
|
||||
if (agentLimit > 0) {
|
||||
logger.debug({
|
||||
msg: 'Applying chat limits',
|
||||
departmentId,
|
||||
agentId,
|
||||
limits: ['department', 'agent'],
|
||||
totalChats,
|
||||
departmentChats,
|
||||
agentLimit,
|
||||
departmentLimit,
|
||||
});
|
||||
return departmentChats < departmentLimit && totalChats < agentLimit;
|
||||
}
|
||||
|
||||
if (globalLimit > 0) {
|
||||
logger.debug({
|
||||
msg: 'Applying chat limits',
|
||||
departmentId,
|
||||
agentId,
|
||||
limits: ['department', 'global'],
|
||||
totalChats,
|
||||
departmentChats,
|
||||
globalLimit,
|
||||
departmentLimit,
|
||||
});
|
||||
return departmentChats < departmentLimit && totalChats < globalLimit;
|
||||
}
|
||||
|
||||
logger.debug({
|
||||
msg: 'Applying chat limits',
|
||||
departmentId,
|
||||
agentId,
|
||||
limits: ['department'],
|
||||
totalChats,
|
||||
departmentChats,
|
||||
departmentLimit,
|
||||
});
|
||||
return departmentChats < departmentLimit;
|
||||
}
|
||||
}
|
||||
|
||||
if (agentLimit > 0) {
|
||||
logger.debug({
|
||||
msg: 'Applying chat limits',
|
||||
departmentId,
|
||||
agentId,
|
||||
limits: ['agent'],
|
||||
totalChats,
|
||||
agentLimit,
|
||||
});
|
||||
return totalChats < agentLimit;
|
||||
}
|
||||
|
||||
if (globalLimit > 0) {
|
||||
logger.debug({
|
||||
msg: 'Applying chat limits',
|
||||
departmentId,
|
||||
agentId,
|
||||
limits: ['global'],
|
||||
totalChats,
|
||||
globalLimit,
|
||||
});
|
||||
return totalChats < globalLimit;
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'No applicable limit found for user', agentId });
|
||||
return true;
|
||||
};
|
||||
|
||||
const getWaitingQueueMessage = async (departmentId?: string) => {
|
||||
const department = departmentId ? await LivechatDepartmentRaw.findOneById(departmentId) : null;
|
||||
if (department?.waitingQueueMessage) {
|
||||
return department.waitingQueueMessage;
|
||||
}
|
||||
|
||||
return settings.get<string>('Livechat_waiting_queue_message');
|
||||
};
|
||||
|
||||
const getQueueInfo = async (department?: string) => {
|
||||
const numberMostRecentChats = settings.get<number>('Livechat_number_most_recent_chats_estimate_wait_time');
|
||||
const statistics = await RoomRaw.getMostRecentAverageChatDurationTime(numberMostRecentChats, department);
|
||||
const text = await getWaitingQueueMessage(department);
|
||||
const message = {
|
||||
text,
|
||||
user: { _id: 'rocket.cat', username: 'rocket.cat' },
|
||||
};
|
||||
return { message, statistics, numberMostRecentChats };
|
||||
};
|
||||
|
||||
const getSpotEstimatedWaitTime = (spot: number, maxNumberSimultaneousChat: number, avgChatDuration: number) => {
|
||||
if (!maxNumberSimultaneousChat || !avgChatDuration) {
|
||||
return;
|
||||
}
|
||||
// X = spot
|
||||
// N = maxNumberSimultaneousChat
|
||||
// Estimated Wait Time = ([(N-1)/X]+1) *Average Chat Time of Most Recent X(Default = 100) Chats
|
||||
return ((spot - 1) / maxNumberSimultaneousChat + 1) * avgChatDuration;
|
||||
};
|
||||
|
||||
const normalizeQueueInfo = async ({
|
||||
position,
|
||||
queueInfo,
|
||||
department,
|
||||
}: {
|
||||
position: number;
|
||||
department?: string;
|
||||
queueInfo?: QueueInfo;
|
||||
}) => {
|
||||
if (!queueInfo) {
|
||||
queueInfo = await getQueueInfo(department);
|
||||
}
|
||||
|
||||
const { message, numberMostRecentChats, statistics: { avgChatDuration } = {} } = queueInfo;
|
||||
const spot = position + 1;
|
||||
const estimatedWaitTimeSeconds = getSpotEstimatedWaitTime(spot, numberMostRecentChats, avgChatDuration);
|
||||
return { spot, message, estimatedWaitTimeSeconds };
|
||||
};
|
||||
|
||||
export const dispatchInquiryPosition = async (inquiry: Omit<InquiryWithAgentInfo, 'v'>, queueInfo?: QueueInfo) => {
|
||||
const { position, department } = inquiry;
|
||||
// Avoid broadcasting if no position was determined
|
||||
if (position === undefined) {
|
||||
return;
|
||||
}
|
||||
const data = await normalizeQueueInfo({ position, queueInfo, department });
|
||||
return setTimeout(() => {
|
||||
void api.broadcast('omnichannel.room', inquiry.rid, {
|
||||
type: 'queueData',
|
||||
data,
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const dispatchWaitingQueueStatus = async (department?: string) => {
|
||||
if (!settings.get('Livechat_waiting_queue') && !settings.get('Omnichannel_calculate_dispatch_service_queue_statistics')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queue = await LivechatInquiry.getCurrentSortedQueueAsync({
|
||||
department,
|
||||
queueSortBy: getOmniChatSortQuery(getInquirySortMechanismSetting()),
|
||||
});
|
||||
|
||||
if (!queue.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queueInfo = await getQueueInfo(department);
|
||||
queue.forEach((inquiry) => {
|
||||
void dispatchInquiryPosition(inquiry, queueInfo);
|
||||
});
|
||||
};
|
||||
|
||||
// When dealing with lots of queued items we need to make sure to notify their position
|
||||
// but we don't need to notify _each_ change that takes place, just their final position
|
||||
export const debouncedDispatchWaitingQueueStatus = memoizeDebounce(dispatchWaitingQueueStatus, 1200);
|
||||
|
||||
export const setPredictedVisitorAbandonmentTime = async (
|
||||
room: Pick<IOmnichannelRoom, '_id' | 'responseBy' | 'departmentId'>,
|
||||
roomUpdater?: Updater<IOmnichannelRoom>,
|
||||
) => {
|
||||
if (
|
||||
!room.responseBy?.firstResponseTs ||
|
||||
!settings.get('Livechat_abandoned_rooms_action') ||
|
||||
settings.get('Livechat_abandoned_rooms_action') === 'none'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let secondsToAdd = settings.get<number>('Livechat_visitor_inactivity_timeout');
|
||||
|
||||
const department = room.departmentId ? await LivechatDepartmentRaw.findOneById(room.departmentId) : null;
|
||||
if (department?.visitorInactivityTimeoutInSeconds) {
|
||||
secondsToAdd = department.visitorInactivityTimeoutInSeconds;
|
||||
}
|
||||
|
||||
if (secondsToAdd <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const willBeAbandonedAt = moment(room.responseBy.firstResponseTs).add(Number(secondsToAdd), 'seconds').toDate();
|
||||
if (roomUpdater) {
|
||||
await LivechatRooms.getPredictedVisitorAbandonmentByRoomIdUpdateQuery(willBeAbandonedAt, roomUpdater);
|
||||
} else {
|
||||
await LivechatRooms.setPredictedVisitorAbandonmentByRoomId(room._id, willBeAbandonedAt);
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePredictedVisitorAbandonment = async () => {
|
||||
if (!settings.get('Livechat_abandoned_rooms_action') || settings.get('Livechat_abandoned_rooms_action') === 'none') {
|
||||
await LivechatRooms.unsetAllPredictedVisitorAbandonment();
|
||||
} else {
|
||||
// Eng day: use a promise queue to update the predicted visitor abandonment time instead of all at once
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {});
|
||||
const promisesArray: Promise<void>[] = [];
|
||||
await LivechatRooms.findOpen(extraQuery).forEach((room) => {
|
||||
promisesArray.push(setPredictedVisitorAbandonmentTime(room));
|
||||
});
|
||||
|
||||
await Promise.all(promisesArray);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateQueueInactivityTimeout = async () => {
|
||||
const queueTimeout = settings.get<number>('Livechat_max_queue_wait_time');
|
||||
if (queueTimeout <= 0) {
|
||||
await OmnichannelQueueInactivityMonitor.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
await LivechatInquiry.getQueuedInquiries({ projection: { _updatedAt: 1 } }).forEach((inq) => {
|
||||
const aggregatedDate = moment(inq._updatedAt).add(queueTimeout, 'minutes');
|
||||
try {
|
||||
void OmnichannelQueueInactivityMonitor.scheduleInquiry(inq._id, new Date(aggregatedDate.format()));
|
||||
} catch (e) {
|
||||
// this will usually happen if other instance attempts to re-create a job
|
||||
logger.error({ err: e });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSLAInquiries = async (
|
||||
executedBy: string,
|
||||
sla?: Pick<IOmnichannelServiceLevelAgreements, '_id' | 'dueTimeInMinutes'>,
|
||||
) => {
|
||||
if (!sla) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _id: slaId } = sla;
|
||||
const promises: Promise<void>[] = [];
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy });
|
||||
await LivechatRooms.findOpenBySlaId(slaId, {}, extraQuery).forEach((room) => {
|
||||
promises.push(updateInquiryQueueSla(room._id, sla));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
};
|
||||
|
||||
export const getLivechatCustomFields = async () => {
|
||||
const customFields = await LivechatCustomField.find({
|
||||
visibility: 'visible',
|
||||
scope: 'visitor',
|
||||
public: true,
|
||||
}).toArray();
|
||||
return customFields.map(({ _id, label, regexp, required = false, type, defaultValue = null, options }) => ({
|
||||
_id,
|
||||
label,
|
||||
regexp,
|
||||
required,
|
||||
type,
|
||||
defaultValue,
|
||||
...(options && options !== '' && { options: options.split(',') }),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getLivechatQueueInfo = async (room?: IOmnichannelRoom) => {
|
||||
if (!room) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!settings.get('Livechat_waiting_queue')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!settings.get('Omnichannel_calculate_dispatch_service_queue_statistics')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { _id: rid, departmentId: department } = room;
|
||||
const inquiry = await LivechatInquiry.findOneByRoomId(rid, { projection: { _id: 1, status: 1 } });
|
||||
if (!inquiry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { _id, status } = inquiry;
|
||||
if (status !== 'queued') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [inq] = await LivechatInquiry.getCurrentSortedQueueAsync({
|
||||
inquiryId: _id,
|
||||
department,
|
||||
queueSortBy: getOmniChatSortQuery(getInquirySortMechanismSetting()),
|
||||
});
|
||||
|
||||
if (!inq) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeQueueInfo(inq);
|
||||
};
|
||||
186
apps/meteor/ee/server/lib/omnichannel/LivechatEnterprise.ts
Normal file
186
apps/meteor/ee/server/lib/omnichannel/LivechatEnterprise.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IOmnichannelBusinessUnit, IOmnichannelServiceLevelAgreements, IUser, ILivechatTag } from '@rocket.chat/core-typings';
|
||||
import { Users, OmnichannelServiceLevelAgreements, LivechatTag, LivechatUnitMonitors, LivechatUnit } from '@rocket.chat/models';
|
||||
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
|
||||
import { Match, check } from 'meteor/check';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { updateSLAInquiries } from './Helper';
|
||||
import { removeSLAFromRooms } from './SlaHelper';
|
||||
import { callbacks } from '../../../../server/lib/callbacks';
|
||||
import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles';
|
||||
import { removeUserFromRolesAsync } from '../../../../server/lib/roles/removeUserFromRoles';
|
||||
|
||||
export const LivechatEnterprise = {
|
||||
async addMonitor(username: string) {
|
||||
const user = await Users.findOneByUsername<Pick<IUser, '_id' | 'username' | 'roles'>>(username, {
|
||||
projection: { _id: 1, username: 1, roles: 1 },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new MeteorError('error-invalid-user', 'Invalid user', {
|
||||
method: 'livechat:addMonitor',
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await addUserRolesAsync(user._id, ['livechat-monitor']))) {
|
||||
throw new MeteorError('error-adding-monitor-role', 'Error adding monitor role', {
|
||||
method: 'livechat:addMonitor',
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
|
||||
async removeMonitor(username: string) {
|
||||
const user = await Users.findOneByUsername<Pick<IUser, '_id'>>(username, {
|
||||
projection: { _id: 1 },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
|
||||
method: 'livechat:removeMonitor',
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await removeUserFromRolesAsync(user._id, ['livechat-monitor']))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// remove this monitor from any unit it is assigned to
|
||||
await LivechatUnitMonitors.removeByMonitorId(user._id);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
async removeUnit(_id: string, userId: string) {
|
||||
check(_id, String);
|
||||
|
||||
const unitsFromUser = await getUnitsFromUser(userId);
|
||||
|
||||
const result = await LivechatUnit.removeByIdAndUnit(_id, unitsFromUser);
|
||||
if (!result.deletedCount) {
|
||||
throw new Meteor.Error('unit-not-found', 'Unit not found', { method: 'livechat:removeUnit' });
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async saveUnit(
|
||||
_id: string | null,
|
||||
unitData: Omit<IOmnichannelBusinessUnit, '_id'>,
|
||||
unitMonitors: { monitorId: string; username: string },
|
||||
unitDepartments: { departmentId: string }[],
|
||||
userId: string,
|
||||
) {
|
||||
check(_id, Match.Maybe(String));
|
||||
|
||||
check(unitData, {
|
||||
name: String,
|
||||
visibility: String,
|
||||
enabled: Match.Optional(Boolean),
|
||||
description: Match.Optional(String),
|
||||
email: Match.Optional(String),
|
||||
showOnOfflineForm: Match.Optional(Boolean),
|
||||
});
|
||||
|
||||
check(unitMonitors, [
|
||||
Match.ObjectIncluding({
|
||||
monitorId: String,
|
||||
username: String,
|
||||
}),
|
||||
]);
|
||||
|
||||
check(unitDepartments, [
|
||||
Match.ObjectIncluding({
|
||||
departmentId: String,
|
||||
}),
|
||||
]);
|
||||
|
||||
let ancestors: string[] = [];
|
||||
if (_id) {
|
||||
const unitsFromUser = await getUnitsFromUser(userId);
|
||||
const unit = await LivechatUnit.findOneById<Pick<IOmnichannelBusinessUnit, '_id' | 'ancestors'>>(
|
||||
_id,
|
||||
{
|
||||
projection: { _id: 1, ancestors: 1 },
|
||||
},
|
||||
{ unitsFromUser },
|
||||
);
|
||||
if (!unit) {
|
||||
throw new Meteor.Error('error-unit-not-found', 'Unit not found', {
|
||||
method: 'livechat:saveUnit',
|
||||
});
|
||||
}
|
||||
|
||||
ancestors = unit.ancestors || [];
|
||||
}
|
||||
|
||||
const validUserMonitors = await Users.findUsersInRolesWithQuery(
|
||||
'livechat-monitor',
|
||||
{ _id: { $in: unitMonitors.map(({ monitorId }) => monitorId) } },
|
||||
{ projection: { _id: 1, username: 1 } },
|
||||
).toArray();
|
||||
|
||||
const monitors = validUserMonitors.map(({ _id: monitorId, username }) => ({
|
||||
monitorId,
|
||||
username: username!,
|
||||
}));
|
||||
|
||||
return LivechatUnit.createOrUpdateUnit(_id, unitData, ancestors, monitors, unitDepartments);
|
||||
},
|
||||
|
||||
async removeTag(_id: string) {
|
||||
const tag = await LivechatTag.findOneById<Pick<ILivechatTag, '_id' | 'name'>>(_id, { projection: { _id: 1, name: 1 } });
|
||||
|
||||
if (!tag) {
|
||||
throw new Meteor.Error('tag-not-found', 'Tag not found', { method: 'livechat:removeTag' });
|
||||
}
|
||||
|
||||
await callbacks.run('livechat.afterTagRemoved', tag);
|
||||
return LivechatTag.removeById(_id);
|
||||
},
|
||||
|
||||
async saveTag(_id: string | undefined, tagData: { name: string; description?: string }, tagDepartments: string[] | undefined) {
|
||||
return LivechatTag.createOrUpdateTag(_id, tagData, tagDepartments);
|
||||
},
|
||||
|
||||
async saveSLA(
|
||||
_id: string | null,
|
||||
slaData: Pick<IOmnichannelServiceLevelAgreements, 'name' | 'description' | 'dueTimeInMinutes'>,
|
||||
executedBy: string,
|
||||
) {
|
||||
const oldSLA =
|
||||
_id &&
|
||||
(await OmnichannelServiceLevelAgreements.findOneById<Pick<IOmnichannelServiceLevelAgreements, 'dueTimeInMinutes'>>(_id, {
|
||||
projection: { dueTimeInMinutes: 1 },
|
||||
}));
|
||||
const exists = await OmnichannelServiceLevelAgreements.findDuplicate(_id, slaData.name, slaData.dueTimeInMinutes);
|
||||
if (exists) {
|
||||
throw new Error('error-duplicated-sla');
|
||||
}
|
||||
|
||||
const sla = await OmnichannelServiceLevelAgreements.createOrUpdatePriority(slaData, _id);
|
||||
if (!oldSLA) {
|
||||
return sla;
|
||||
}
|
||||
|
||||
const { dueTimeInMinutes: oldDueTimeInMinutes } = oldSLA;
|
||||
const { dueTimeInMinutes } = sla;
|
||||
|
||||
if (oldDueTimeInMinutes !== dueTimeInMinutes) {
|
||||
await updateSLAInquiries(executedBy, sla);
|
||||
}
|
||||
|
||||
return sla;
|
||||
},
|
||||
|
||||
async removeSLA(executedBy: string, _id: string) {
|
||||
const removedResult = await OmnichannelServiceLevelAgreements.removeById(_id);
|
||||
if (!removedResult || removedResult.deletedCount !== 1) {
|
||||
throw new Error(`SLA with id ${_id} not found`);
|
||||
}
|
||||
|
||||
await removeSLAFromRooms(_id, executedBy);
|
||||
},
|
||||
};
|
||||
134
apps/meteor/ee/server/lib/omnichannel/QueueInactivityMonitor.ts
Normal file
134
apps/meteor/ee/server/lib/omnichannel/QueueInactivityMonitor.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Agenda } from '@rocket.chat/agenda';
|
||||
import type { IUser, IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import type { MainLogger } from '@rocket.chat/logger';
|
||||
import { LivechatRooms, LivechatInquiry as LivechatInquiryRaw, Users } from '@rocket.chat/models';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { MongoInternals } from 'meteor/mongo';
|
||||
import type { Db } from 'mongodb';
|
||||
|
||||
import { schedulerLogger } from './logger';
|
||||
import { i18n } from '../../../../server/lib/i18n';
|
||||
import { closeRoom } from '../../../../server/lib/omnichannel/closeRoom';
|
||||
import { settings } from '../../../../server/settings';
|
||||
|
||||
const SCHEDULER_NAME = 'omnichannel_queue_inactivity_monitor';
|
||||
|
||||
export class OmnichannelQueueInactivityMonitorClass {
|
||||
scheduler: Agenda;
|
||||
|
||||
running: boolean;
|
||||
|
||||
logger: MainLogger;
|
||||
|
||||
_name: string;
|
||||
|
||||
user: IUser;
|
||||
|
||||
message: string;
|
||||
|
||||
_db: Db;
|
||||
|
||||
bindedCloseRoom: any;
|
||||
|
||||
constructor() {
|
||||
this._db = MongoInternals.defaultRemoteCollectionDriver().mongo.db;
|
||||
this.running = false;
|
||||
this._name = 'Omnichannel-Queue-Inactivity-Monitor';
|
||||
this.logger = schedulerLogger.section(this._name);
|
||||
this.scheduler = new Agenda({
|
||||
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
|
||||
db: { collection: SCHEDULER_NAME },
|
||||
defaultConcurrency: 1,
|
||||
processEvery: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? '3 seconds' : '1 minute',
|
||||
});
|
||||
this.createIndex();
|
||||
const language = settings.get<string>('Language') || 'en';
|
||||
this.message = i18n.t('Closed_automatically_chat_queued_too_long', { lng: language });
|
||||
this.bindedCloseRoom = this.closeRoom.bind(this);
|
||||
}
|
||||
|
||||
private async getRocketCatUser(): Promise<IUser | null> {
|
||||
return Users.findOneById('rocket.cat');
|
||||
}
|
||||
|
||||
getName(inquiryId: string): string {
|
||||
return `${this._name}-${inquiryId}`;
|
||||
}
|
||||
|
||||
createIndex(): void {
|
||||
void this._db.collection(SCHEDULER_NAME).createIndex(
|
||||
{
|
||||
'data.inquiryId': 1,
|
||||
},
|
||||
{ unique: true },
|
||||
);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.scheduler.start();
|
||||
this.logger.info('Service started');
|
||||
this.running = true;
|
||||
}
|
||||
|
||||
async scheduleInquiry(inquiryId: string, time: Date): Promise<void> {
|
||||
await this.stopInquiry(inquiryId);
|
||||
this.logger.debug({ msg: 'Scheduling automatic close of inquiry', inquiryId, scheduledAt: time });
|
||||
const name = this.getName(inquiryId);
|
||||
this.scheduler.define(name, this.bindedCloseRoom);
|
||||
|
||||
const job = this.scheduler.create(name, { inquiryId });
|
||||
job.schedule(time);
|
||||
job.unique({ 'data.inquiryId': inquiryId });
|
||||
await job.save();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
await this.scheduler.cancel({});
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
async stopInquiry(inquiryId: string): Promise<void> {
|
||||
const name = this.getName(inquiryId);
|
||||
await this.scheduler.cancel({ name });
|
||||
}
|
||||
|
||||
async closeRoomAction(room: IOmnichannelRoom): Promise<void> {
|
||||
const comment = this.message;
|
||||
return closeRoom({
|
||||
comment,
|
||||
room,
|
||||
user: await this.getRocketCatUser(),
|
||||
});
|
||||
}
|
||||
|
||||
async closeRoom({ attrs: { data } }: any = {}): Promise<void> {
|
||||
const { inquiryId } = data;
|
||||
// TODO: add projection and maybe use findOneQueued to avoid fetching the whole inquiry
|
||||
const inquiry = await LivechatInquiryRaw.findOneById(inquiryId);
|
||||
if (inquiry?.status !== 'queued') {
|
||||
return;
|
||||
}
|
||||
|
||||
const room = await LivechatRooms.findOneById(inquiry.rid);
|
||||
if (!room) {
|
||||
this.logger.error({ msg: 'Unable to find room to close in queue inactivity monitor', inquiryId, roomId: inquiry.rid });
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([this.closeRoomAction(room), this.stopInquiry(inquiryId)]);
|
||||
this.logger.info({ msg: 'Closed room due to queue inactivity', roomId: inquiry.rid, inquiryId });
|
||||
}
|
||||
}
|
||||
|
||||
export const OmnichannelQueueInactivityMonitor = new OmnichannelQueueInactivityMonitorClass();
|
||||
|
||||
Meteor.startup(async () => {
|
||||
void OmnichannelQueueInactivityMonitor.start();
|
||||
});
|
||||
69
apps/meteor/ee/server/lib/omnichannel/SlaHelper.ts
Normal file
69
apps/meteor/ee/server/lib/omnichannel/SlaHelper.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Message } from '@rocket.chat/core-services';
|
||||
import type { IOmnichannelServiceLevelAgreements, IUser } from '@rocket.chat/core-typings';
|
||||
import { LivechatInquiry, LivechatRooms } from '@rocket.chat/models';
|
||||
|
||||
import { callbacks } from '../../../../server/lib/callbacks';
|
||||
import {
|
||||
notifyOnRoomChangedById,
|
||||
notifyOnLivechatInquiryChangedByRoom,
|
||||
notifyOnLivechatInquiryChanged,
|
||||
} from '../../../../server/lib/notifyListener';
|
||||
|
||||
export const removeSLAFromRooms = async (slaId: string, userId: string) => {
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId });
|
||||
const openRooms = await LivechatRooms.findOpenBySlaId(slaId, { projection: { _id: 1 } }, extraQuery).toArray();
|
||||
const openRoomIds: string[] = openRooms.map(({ _id }) => _id);
|
||||
if (openRooms.length) {
|
||||
await LivechatInquiry.bulkUnsetSla(openRoomIds);
|
||||
void notifyOnLivechatInquiryChangedByRoom(openRoomIds, 'updated');
|
||||
}
|
||||
|
||||
await LivechatRooms.bulkRemoveSlaFromRoomsById(slaId);
|
||||
void notifyOnRoomChangedById(openRoomIds, 'updated');
|
||||
};
|
||||
|
||||
export const updateInquiryQueueSla = async (roomId: string, sla: Pick<IOmnichannelServiceLevelAgreements, 'dueTimeInMinutes' | '_id'>) => {
|
||||
const inquiry = await LivechatInquiry.findOneByRoomId(roomId);
|
||||
if (!inquiry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dueTimeInMinutes, _id: slaId } = sla;
|
||||
|
||||
const estimatedWaitingTimeQueue = dueTimeInMinutes;
|
||||
|
||||
await LivechatInquiry.setSlaForRoom(inquiry.rid, {
|
||||
slaId,
|
||||
estimatedWaitingTimeQueue,
|
||||
});
|
||||
|
||||
void notifyOnLivechatInquiryChanged({ ...inquiry, slaId, estimatedWaitingTimeQueue, _updatedAt: new Date() }, 'updated');
|
||||
};
|
||||
|
||||
export const updateRoomSlaWeights = async (roomId: string, sla: Pick<IOmnichannelServiceLevelAgreements, 'dueTimeInMinutes' | '_id'>) => {
|
||||
await LivechatRooms.setSlaForRoomById(roomId, sla);
|
||||
};
|
||||
|
||||
export const removeInquiryQueueSla = async (roomId: string) => {
|
||||
await LivechatInquiry.unsetSlaForRoom(roomId);
|
||||
};
|
||||
|
||||
export const removeSlaFromRoom = async (roomId: string) => {
|
||||
await LivechatRooms.removeSlaFromRoomById(roomId);
|
||||
};
|
||||
|
||||
export const addSlaChangeHistoryToRoom = async (
|
||||
roomId: string,
|
||||
user: Pick<IUser, '_id' | 'name' | 'username'>,
|
||||
sla?: Pick<IOmnichannelServiceLevelAgreements, 'name'>,
|
||||
) => {
|
||||
await Message.saveSystemMessage('omnichannel_sla_change_history', roomId, '', user, {
|
||||
slaData: {
|
||||
definedBy: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
},
|
||||
...(sla && { sla }),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { OmnichannelEEService } from '@rocket.chat/core-services';
|
||||
import type { ILivechatVisitor, IOmnichannelRoom, IUser, ILivechatDepartment } from '@rocket.chat/core-typings';
|
||||
import { cronJobs } from '@rocket.chat/cron';
|
||||
import type { MainLogger } from '@rocket.chat/logger';
|
||||
import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models';
|
||||
|
||||
import { schedulerLogger } from './logger';
|
||||
import { callbacks } from '../../../../server/lib/callbacks';
|
||||
import { i18n } from '../../../../server/lib/i18n';
|
||||
import { notifyOnRoomChangedById } from '../../../../server/lib/notifyListener';
|
||||
import { closeRoom } from '../../../../server/lib/omnichannel/closeRoom';
|
||||
import { settings } from '../../../../server/settings';
|
||||
|
||||
const isPromiseRejectedResult = (result: any): result is PromiseRejectedResult => result && result.status === 'rejected';
|
||||
|
||||
export class VisitorInactivityMonitor {
|
||||
_started: boolean;
|
||||
|
||||
_name: string;
|
||||
|
||||
messageCache: Map<string, string>;
|
||||
|
||||
user: IUser;
|
||||
|
||||
logger: MainLogger;
|
||||
|
||||
private scheduler = cronJobs;
|
||||
|
||||
constructor() {
|
||||
this._started = false;
|
||||
this._name = 'Omnichannel Visitor Inactivity Monitor';
|
||||
this.messageCache = new Map();
|
||||
this.logger = schedulerLogger.section(this._name);
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this._startMonitoring();
|
||||
this._initializeMessageCache();
|
||||
const cat = await Users.findOneById('rocket.cat');
|
||||
if (cat) {
|
||||
this.user = cat;
|
||||
}
|
||||
}
|
||||
|
||||
private async _startMonitoring() {
|
||||
if (this.isRunning()) {
|
||||
this.logger.debug('Already running');
|
||||
return;
|
||||
}
|
||||
const everyMinute = '* * * * *';
|
||||
await this.scheduler.add(this._name, everyMinute, async () => this.handleAbandonedRooms());
|
||||
this._started = true;
|
||||
this.logger.info('Service started');
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.isRunning()) {
|
||||
return;
|
||||
}
|
||||
await this.scheduler.remove(this._name);
|
||||
|
||||
this._started = false;
|
||||
this.logger.info('Service stopped');
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return this._started;
|
||||
}
|
||||
|
||||
_initializeMessageCache() {
|
||||
this.messageCache.clear();
|
||||
}
|
||||
|
||||
async _getDepartmentAbandonedCustomMessage(departmentId: string) {
|
||||
if (this.messageCache.has(departmentId)) {
|
||||
return this.messageCache.get(departmentId);
|
||||
}
|
||||
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'abandonedRoomsCloseCustomMessage'>>(
|
||||
departmentId,
|
||||
{ projection: { _id: 1, abandonedRoomsCloseCustomMessage: 1 } },
|
||||
);
|
||||
if (!department) {
|
||||
this.logger.error({ msg: 'Department not found', departmentId });
|
||||
return;
|
||||
}
|
||||
this.messageCache.set(department._id, department.abandonedRoomsCloseCustomMessage);
|
||||
return department.abandonedRoomsCloseCustomMessage;
|
||||
}
|
||||
|
||||
async closeRooms(room: IOmnichannelRoom) {
|
||||
let comment = await this.getDefaultAbandonedCustomMessage('close', room.v._id);
|
||||
if (room.departmentId) {
|
||||
comment = (await this._getDepartmentAbandonedCustomMessage(room.departmentId)) || comment;
|
||||
}
|
||||
await closeRoom({
|
||||
comment,
|
||||
room,
|
||||
user: this.user,
|
||||
});
|
||||
void notifyOnRoomChangedById(room._id);
|
||||
this.logger.info({ msg: 'Closed room due to visitor inactivity', roomId: room._id });
|
||||
}
|
||||
|
||||
async placeRoomOnHold(room: IOmnichannelRoom) {
|
||||
const comment = await this.getDefaultAbandonedCustomMessage('on-hold', room.v._id);
|
||||
|
||||
const result = await Promise.allSettled([
|
||||
OmnichannelEEService.placeRoomOnHold(room, comment, this.user),
|
||||
LivechatRooms.unsetPredictedVisitorAbandonmentByRoomId(room._id),
|
||||
]);
|
||||
const rejected = result.filter(isPromiseRejectedResult).map((r) => r.reason);
|
||||
if (rejected.length) {
|
||||
this.logger.error({ msg: 'Error placing room on hold', error: rejected });
|
||||
throw new Error('Error placing room on hold. Please check logs for more details.');
|
||||
}
|
||||
|
||||
void notifyOnRoomChangedById(room._id);
|
||||
}
|
||||
|
||||
async handleAbandonedRooms() {
|
||||
const action = settings.get<string>('Livechat_abandoned_rooms_action');
|
||||
if (!action || action === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {});
|
||||
const promises: Promise<void>[] = [];
|
||||
await LivechatRooms.findAbandonedOpenRooms(new Date(), extraQuery).forEach((room) => {
|
||||
switch (action) {
|
||||
case 'close': {
|
||||
this.logger.info({ msg: 'Closing room due to abandoned visitor', roomId: room._id });
|
||||
promises.push(this.closeRooms(room));
|
||||
break;
|
||||
}
|
||||
case 'on-hold': {
|
||||
this.logger.info({ msg: 'Placing room on hold due to abandoned visitor', roomId: room._id });
|
||||
promises.push(this.placeRoomOnHold(room));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = await Promise.allSettled(promises);
|
||||
|
||||
const errors = result.filter(isPromiseRejectedResult).map((r) => r.reason);
|
||||
|
||||
if (errors.length) {
|
||||
this.logger.error({ msg: 'Error while removing priority from rooms', count: errors.length, reason: errors[0] });
|
||||
}
|
||||
|
||||
this._initializeMessageCache();
|
||||
}
|
||||
|
||||
private async getDefaultAbandonedCustomMessage(abandonmentAction: 'close' | 'on-hold', visitorId: string) {
|
||||
const visitor = await LivechatVisitors.findOneEnabledById<Pick<ILivechatVisitor, 'name' | 'username'>>(visitorId, {
|
||||
projection: {
|
||||
name: 1,
|
||||
username: 1,
|
||||
},
|
||||
});
|
||||
if (!visitor) {
|
||||
this.logger.error({
|
||||
msg: 'Error getting default abandoned custom message: visitor not found',
|
||||
visitorId,
|
||||
});
|
||||
throw new Error('error-invalid_visitor');
|
||||
}
|
||||
|
||||
const timeout = settings.get<number>('Livechat_visitor_inactivity_timeout');
|
||||
|
||||
const guest = visitor.name || visitor.username;
|
||||
|
||||
if (abandonmentAction === 'on-hold') {
|
||||
return i18n.t('Omnichannel_On_Hold_due_to_inactivity', {
|
||||
guest,
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
settings.get<string>('Livechat_abandoned_rooms_closed_custom_message') ||
|
||||
i18n.t('Omnichannel_chat_closed_due_to_inactivity', {
|
||||
guest,
|
||||
timeout,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
180
apps/meteor/ee/server/lib/omnichannel/business-hour/Custom.ts
Normal file
180
apps/meteor/ee/server/lib/omnichannel/business-hour/Custom.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
|
||||
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models';
|
||||
|
||||
import { businessHourManager } from '../../../../../server/lib/omnichannel/business-hour';
|
||||
import type { IBusinessHourType } from '../../../../../server/lib/omnichannel/business-hour/AbstractBusinessHour';
|
||||
import { AbstractBusinessHourType } from '../../../../../server/lib/omnichannel/business-hour/AbstractBusinessHour';
|
||||
import {
|
||||
filterBusinessHoursThatMustBeOpened,
|
||||
makeAgentsUnavailableBasedOnBusinessHour,
|
||||
} from '../../../../../server/lib/omnichannel/business-hour/Helper';
|
||||
import { bhLogger } from '../logger';
|
||||
|
||||
type IBusinessHoursExtraProperties = {
|
||||
timezoneName: string;
|
||||
departmentsToApplyBusinessHour?: string;
|
||||
};
|
||||
|
||||
class CustomBusinessHour extends AbstractBusinessHourType implements IBusinessHourType {
|
||||
name = LivechatBusinessHourTypes.CUSTOM;
|
||||
|
||||
async getBusinessHour(id: string): Promise<ILivechatBusinessHour | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const businessHour = await this.BusinessHourRepository.findOneById(id);
|
||||
if (!businessHour) {
|
||||
return null;
|
||||
}
|
||||
|
||||
businessHour.departments = await LivechatDepartment.findByBusinessHourId(businessHour._id, {
|
||||
projection: { name: 1 },
|
||||
}).toArray();
|
||||
return businessHour;
|
||||
}
|
||||
|
||||
async saveBusinessHour(businessHour: ILivechatBusinessHour & IBusinessHoursExtraProperties): Promise<ILivechatBusinessHour> {
|
||||
const existingBusinessHour = (await this.BusinessHourRepository.findOne(
|
||||
{ name: businessHour.name },
|
||||
{ projection: { _id: 1 } },
|
||||
)) as ILivechatBusinessHour;
|
||||
if (existingBusinessHour && existingBusinessHour._id !== businessHour._id) {
|
||||
throw new Error('error-business-hour-name-already-in-use');
|
||||
}
|
||||
const { timezoneName, departmentsToApplyBusinessHour, ...businessHourData } = businessHour;
|
||||
businessHourData.timezone = {
|
||||
name: timezoneName,
|
||||
utc: this.getUTCFromTimezone(timezoneName),
|
||||
};
|
||||
const businessHourToReturn = { ...businessHourData, departmentsToApplyBusinessHour };
|
||||
delete businessHourData.departments;
|
||||
|
||||
const businessHourId = await this.baseSaveBusinessHour(businessHourData);
|
||||
|
||||
// Internal callers (e.g. the DST verifier) re-save business hours without the
|
||||
// departments field; only reconcile department links when it is provided,
|
||||
// otherwise an internal re-save would unlink every department.
|
||||
if (departmentsToApplyBusinessHour !== undefined) {
|
||||
const departments = departmentsToApplyBusinessHour.split(',').filter(Boolean);
|
||||
const currentDepartments = (
|
||||
await LivechatDepartment.findByBusinessHourId(businessHourId, {
|
||||
projection: { _id: 1 },
|
||||
}).toArray()
|
||||
).map((dept) => dept._id);
|
||||
const toRemove = [...currentDepartments.filter((dept) => !departments.includes(dept))];
|
||||
const toAdd = [...departments.filter((dept: string) => !currentDepartments.includes(dept))];
|
||||
|
||||
await this.removeBusinessHourFromDepartmentsIfNeeded(businessHourId, toRemove);
|
||||
await this.removeBusinessHourFromDepartmentsAgents(businessHourId, toRemove);
|
||||
|
||||
// Now will check if the department which we're currently adding to BH is not
|
||||
// associated with any other BH. If it is, then it will remove the old BH from all user's
|
||||
// cache. It will not add the new BH right now as it will be done in afterSaveBusinessHour.
|
||||
await this.removeBHFromPreviouslyConnectedDepartmentAgentsIfRequired(toAdd);
|
||||
|
||||
await this.addBusinessHourToDepartmentsIfNeeded(businessHourId, toAdd);
|
||||
}
|
||||
|
||||
businessHourToReturn._id = businessHourId;
|
||||
return businessHourToReturn;
|
||||
}
|
||||
|
||||
async removeBusinessHourById(businessHourId: string): Promise<void> {
|
||||
const businessHour = await this.BusinessHourRepository.findOneById(businessHourId, {});
|
||||
if (!businessHour) {
|
||||
return;
|
||||
}
|
||||
await this.BusinessHourRepository.removeById(businessHourId);
|
||||
await this.removeBusinessHourFromAgents(businessHourId);
|
||||
await LivechatDepartment.removeBusinessHourFromDepartmentsByBusinessHourId(businessHourId);
|
||||
|
||||
await makeAgentsUnavailableBasedOnBusinessHour();
|
||||
}
|
||||
|
||||
private async removeBusinessHourFromAgents(businessHourId: string): Promise<void> {
|
||||
const departmentIds = (
|
||||
await LivechatDepartment.findByBusinessHourId(businessHourId, {
|
||||
projection: { _id: 1 },
|
||||
}).toArray()
|
||||
).map((dept) => dept._id);
|
||||
await this.removeBusinessHourFromDepartmentsAgents(businessHourId, departmentIds);
|
||||
}
|
||||
|
||||
private async removeBusinessHourFromDepartmentsAgents(businessHourId: string, departmentIds: string[]): Promise<void> {
|
||||
if (!departmentIds.length) {
|
||||
return;
|
||||
}
|
||||
const agentIds = (
|
||||
await LivechatDepartmentAgents.findByDepartmentIds(departmentIds, {
|
||||
projection: { agentId: 1 },
|
||||
}).toArray()
|
||||
).map((dept) => dept.agentId);
|
||||
await this.UsersRepository.removeBusinessHourByAgentIds(agentIds, businessHourId);
|
||||
}
|
||||
|
||||
private async removeBusinessHourFromDepartmentsIfNeeded(businessHourId: string, departmentsToRemove: string[]): Promise<void> {
|
||||
if (!departmentsToRemove.length) {
|
||||
return;
|
||||
}
|
||||
await LivechatDepartment.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId(departmentsToRemove, businessHourId);
|
||||
}
|
||||
|
||||
private async removeBHFromPreviouslyConnectedDepartmentAgentsIfRequired(departmentIds: string[]): Promise<void> {
|
||||
// we need to do 2 things here.
|
||||
// 1st is to check if any of the departments are associated with any BH. If they are, then we need to remove
|
||||
// that BH from all agents of that department.
|
||||
// 2nd is to check if any of the departments are not associated with BH, meaning default BH
|
||||
// is applied. So if default BH is open, then we need to remove it from all agents of that department.
|
||||
const bhIdsConnectedToDepartment = [];
|
||||
const departmentsWithoutBH = [];
|
||||
|
||||
const departments = await LivechatDepartment.findInIds(departmentIds, {
|
||||
projection: {
|
||||
businessHourId: 1,
|
||||
},
|
||||
}).toArray();
|
||||
|
||||
for (const dept of departments) {
|
||||
if (dept?.businessHourId) {
|
||||
bhIdsConnectedToDepartment.push(dept.businessHourId);
|
||||
} else {
|
||||
departmentsWithoutBH.push(dept._id);
|
||||
}
|
||||
}
|
||||
await Users.closeAgentsBusinessHoursByBusinessHourIds(bhIdsConnectedToDepartment);
|
||||
|
||||
// start of step 2
|
||||
const defaultBusinessHour = await this.BusinessHourRepository.findOneDefaultBusinessHour();
|
||||
if (!defaultBusinessHour) {
|
||||
bhLogger.error('No default business hour found');
|
||||
return;
|
||||
}
|
||||
|
||||
const isDefaultBHActive = (await filterBusinessHoursThatMustBeOpened([defaultBusinessHour])).length > 0;
|
||||
if (!isDefaultBHActive) {
|
||||
bhLogger.debug('Default business hour is not active. No need to remove it from agents');
|
||||
return;
|
||||
}
|
||||
|
||||
const agentsConnectedToDefaultBH = await (
|
||||
await LivechatDepartmentAgents.findByDepartmentIds(departmentIds, {
|
||||
projection: { agentId: 1 },
|
||||
}).toArray()
|
||||
).map((dept) => dept.agentId);
|
||||
|
||||
await Users.removeBusinessHourByAgentIds(agentsConnectedToDefaultBH, defaultBusinessHour._id);
|
||||
|
||||
await makeAgentsUnavailableBasedOnBusinessHour();
|
||||
}
|
||||
|
||||
private async addBusinessHourToDepartmentsIfNeeded(businessHourId: string, departmentsToAdd: string[]): Promise<void> {
|
||||
if (!departmentsToAdd.length) {
|
||||
return;
|
||||
}
|
||||
await LivechatDepartment.addBusinessHourToDepartmentsByIds(departmentsToAdd, businessHourId);
|
||||
}
|
||||
}
|
||||
|
||||
businessHourManager.registerBusinessHourType(new CustomBusinessHour());
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
|
||||
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models';
|
||||
|
||||
import {
|
||||
makeAgentsUnavailableBasedOnBusinessHour,
|
||||
makeOnlineAgentsAvailable,
|
||||
} from '../../../../../server/lib/omnichannel/business-hour/Helper';
|
||||
import { getAgentIdsForBusinessHour } from '../../../../../server/lib/omnichannel/business-hour/getAgentIdsForBusinessHour';
|
||||
import { businessHourLogger } from '../../../../../server/lib/omnichannel/logger';
|
||||
|
||||
export const getAgentIdsToHandle = async (businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>): Promise<string[]> => {
|
||||
if (businessHour.type === LivechatBusinessHourTypes.DEFAULT) {
|
||||
return getAgentIdsForBusinessHour();
|
||||
}
|
||||
const departmentIds = (
|
||||
await LivechatDepartment.findEnabledByBusinessHourId(businessHour._id, {
|
||||
projection: { _id: 1 },
|
||||
}).toArray()
|
||||
).map((dept) => dept._id);
|
||||
return (
|
||||
await LivechatDepartmentAgents.findByDepartmentIds(departmentIds, {
|
||||
projection: { agentId: 1 },
|
||||
}).toArray()
|
||||
).map((dept) => dept.agentId);
|
||||
};
|
||||
|
||||
export const openBusinessHour = async (
|
||||
businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>,
|
||||
updateLivechatStatus = true,
|
||||
): Promise<void> => {
|
||||
const agentIds = await getAgentIdsToHandle(businessHour);
|
||||
businessHourLogger.debug({
|
||||
msg: 'Opening business hour',
|
||||
businessHour: businessHour._id,
|
||||
totalAgents: agentIds.length,
|
||||
top10AgentIds: agentIds.slice(0, 10),
|
||||
});
|
||||
await Users.addBusinessHourByAgentIds(agentIds, businessHour._id);
|
||||
await makeOnlineAgentsAvailable(agentIds);
|
||||
|
||||
if (updateLivechatStatus) {
|
||||
await makeAgentsUnavailableBasedOnBusinessHour();
|
||||
}
|
||||
};
|
||||
|
||||
export const removeBusinessHourByAgentIds = async (agentIds: string[], businessHourId: string): Promise<void> => {
|
||||
if (!agentIds.length) {
|
||||
return;
|
||||
}
|
||||
await Users.removeBusinessHourByAgentIds(agentIds, businessHourId);
|
||||
await makeAgentsUnavailableBasedOnBusinessHour();
|
||||
};
|
||||
362
apps/meteor/ee/server/lib/omnichannel/business-hour/Multiple.ts
Normal file
362
apps/meteor/ee/server/lib/omnichannel/business-hour/Multiple.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
|
||||
import type { AtLeast, ILivechatDepartment, ILivechatBusinessHour } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models';
|
||||
import { isTruthy } from '@rocket.chat/tools';
|
||||
import moment from 'moment';
|
||||
|
||||
import { openBusinessHour, removeBusinessHourByAgentIds } from './Helper';
|
||||
import { businessHourManager } from '../../../../../server/lib/omnichannel/business-hour';
|
||||
import type { IBusinessHourBehavior } from '../../../../../server/lib/omnichannel/business-hour/AbstractBusinessHour';
|
||||
import { AbstractBusinessHourBehavior } from '../../../../../server/lib/omnichannel/business-hour/AbstractBusinessHour';
|
||||
import {
|
||||
filterBusinessHoursThatMustBeOpened,
|
||||
filterBusinessHoursThatMustBeOpenedByDay,
|
||||
makeOnlineAgentsAvailable,
|
||||
makeAgentsUnavailableBasedOnBusinessHour,
|
||||
} from '../../../../../server/lib/omnichannel/business-hour/Helper';
|
||||
import { closeBusinessHour } from '../../../../../server/lib/omnichannel/business-hour/closeBusinessHour';
|
||||
import { settings } from '../../../../../server/settings';
|
||||
import { bhLogger } from '../logger';
|
||||
|
||||
export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior implements IBusinessHourBehavior {
|
||||
constructor() {
|
||||
super();
|
||||
this.onAddAgentToDepartment = this.onAddAgentToDepartment.bind(this);
|
||||
this.onRemoveAgentFromDepartment = this.onRemoveAgentFromDepartment.bind(this);
|
||||
this.onRemoveDepartment = this.onRemoveDepartment.bind(this);
|
||||
this.onDepartmentArchived = this.onDepartmentArchived.bind(this);
|
||||
this.onDepartmentDisabled = this.onDepartmentDisabled.bind(this);
|
||||
this.onNewAgentCreated = this.onNewAgentCreated.bind(this);
|
||||
}
|
||||
|
||||
async onStartBusinessHours(): Promise<void> {
|
||||
try {
|
||||
await this.UsersRepository.removeBusinessHoursFromAllUsers();
|
||||
|
||||
// TODO is this required? since we're calling `this.openBusinessHour(businessHour)` later on, which will call this again (kinda)
|
||||
await makeAgentsUnavailableBasedOnBusinessHour();
|
||||
|
||||
const currentTime = moment.utc(moment().utc().format('dddd:HH:mm'), 'dddd:HH:mm');
|
||||
const day = currentTime.format('dddd');
|
||||
const activeBusinessHours = await this.BusinessHourRepository.findActiveAndOpenBusinessHoursByDay(day, {
|
||||
projection: {
|
||||
workHours: 1,
|
||||
timezone: 1,
|
||||
type: 1,
|
||||
active: 1,
|
||||
},
|
||||
});
|
||||
const businessHoursToOpen = await filterBusinessHoursThatMustBeOpened(activeBusinessHours);
|
||||
bhLogger.info({
|
||||
msg: 'Starting Multiple Business Hours',
|
||||
totalBusinessHoursToOpen: businessHoursToOpen.length,
|
||||
top10BusinessHoursToOpen: businessHoursToOpen.slice(0, 10),
|
||||
});
|
||||
for (const businessHour of businessHoursToOpen) {
|
||||
void this.openBusinessHour(businessHour).catch((err) => {
|
||||
bhLogger.error({ msg: 'Error while opening business hour during start', businessHourId: businessHour._id, err });
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
bhLogger.error({ msg: 'Error while starting business hours', err });
|
||||
}
|
||||
}
|
||||
|
||||
async openBusinessHoursByDayAndHour(day: string, hour: string): Promise<void> {
|
||||
const businessHours = await this.BusinessHourRepository.findActiveBusinessHoursToOpen(day, hour, undefined, {
|
||||
projection: {
|
||||
_id: 1,
|
||||
type: 1,
|
||||
},
|
||||
});
|
||||
for (const businessHour of businessHours) {
|
||||
void this.openBusinessHour(businessHour);
|
||||
}
|
||||
}
|
||||
|
||||
async closeBusinessHoursByDayAndHour(day: string, hour: string): Promise<void> {
|
||||
const businessHours = await this.BusinessHourRepository.findActiveBusinessHoursToClose(day, hour, undefined, {
|
||||
projection: {
|
||||
_id: 1,
|
||||
type: 1,
|
||||
},
|
||||
});
|
||||
for (const businessHour of businessHours) {
|
||||
void this.closeBusinessHour(businessHour);
|
||||
}
|
||||
}
|
||||
|
||||
async afterSaveBusinessHours(businessHourData: ILivechatBusinessHour): Promise<void> {
|
||||
const businessHour = await this.BusinessHourRepository.findOneById(businessHourData._id);
|
||||
if (!businessHour) {
|
||||
return;
|
||||
}
|
||||
const businessHourIdToOpen = (await filterBusinessHoursThatMustBeOpened([businessHour])).map((businessHour) => businessHour._id);
|
||||
if (!businessHourIdToOpen.length) {
|
||||
return closeBusinessHour(businessHour);
|
||||
}
|
||||
return openBusinessHour(businessHour);
|
||||
}
|
||||
|
||||
async onAddAgentToDepartment(options: { departmentId: string; agentsId: string[] }): Promise<any> {
|
||||
const { departmentId, agentsId } = options;
|
||||
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'businessHourId'>>(departmentId, {
|
||||
projection: { businessHourId: 1 },
|
||||
});
|
||||
if (!department || !agentsId.length) {
|
||||
return options;
|
||||
}
|
||||
const defaultBusinessHour = await this.BusinessHourRepository.findOneDefaultBusinessHour();
|
||||
if (!defaultBusinessHour) {
|
||||
return options;
|
||||
}
|
||||
if (!department.businessHourId) {
|
||||
// If this department doesn't have a business hour, we need to apply default business hour to these agents
|
||||
// And then reset their status based on these BH
|
||||
const isDefaultBusinessHourActive = (await filterBusinessHoursThatMustBeOpened([defaultBusinessHour])).length > 0;
|
||||
if (!isDefaultBusinessHourActive) {
|
||||
bhLogger.debug('Default business hour is not active. No need to apply it to agents');
|
||||
return options;
|
||||
}
|
||||
|
||||
await this.UsersRepository.addBusinessHourByAgentIds(agentsId, defaultBusinessHour._id);
|
||||
await makeOnlineAgentsAvailable(agentsId);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// This department has a business hour, so we need to
|
||||
// 1. Remove default business hour from these agents if they have it
|
||||
// 2. Add this department's business hour to these agents
|
||||
// 3. Update their status based on these BH
|
||||
await removeBusinessHourByAgentIds(agentsId, defaultBusinessHour._id);
|
||||
|
||||
const businessHour = await this.BusinessHourRepository.findOneById(department.businessHourId);
|
||||
if (!businessHour) {
|
||||
return options;
|
||||
}
|
||||
const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([businessHour]);
|
||||
if (!businessHourToOpen.length) {
|
||||
return options;
|
||||
}
|
||||
|
||||
await this.UsersRepository.addBusinessHourByAgentIds(agentsId, businessHour._id);
|
||||
await makeOnlineAgentsAvailable(agentsId);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async onRemoveAgentFromDepartment(options: Record<string, any> = {}): Promise<any> {
|
||||
const { departmentId, agentsId } = options;
|
||||
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'businessHourId'>>(departmentId, {
|
||||
projection: { businessHourId: 1 },
|
||||
});
|
||||
if (!department || !agentsId.length) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return this.handleRemoveAgentsFromDepartments(department, agentsId, options);
|
||||
}
|
||||
|
||||
async onRemoveDepartment(options: { department: AtLeast<ILivechatDepartment, '_id' | 'businessHourId'>; agentsIds: string[] }) {
|
||||
const { department, agentsIds } = options;
|
||||
if (!department || !agentsIds?.length) {
|
||||
return options;
|
||||
}
|
||||
return this.onDepartmentDisabled(department);
|
||||
}
|
||||
|
||||
async onDepartmentDisabled(department: AtLeast<ILivechatDepartment, 'businessHourId' | '_id'>): Promise<void> {
|
||||
if (!department.businessHourId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get business hour
|
||||
let businessHour = await this.BusinessHourRepository.findOneById(department.businessHourId);
|
||||
if (!businessHour) {
|
||||
bhLogger.error({
|
||||
msg: 'onDepartmentDisabled: business hour not found',
|
||||
businessHourId: department.businessHourId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Unlink business hour from department
|
||||
await LivechatDepartment.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId([department._id], businessHour._id);
|
||||
|
||||
// cleanup user's cache for default business hour and this business hour
|
||||
const defaultBH = await this.BusinessHourRepository.findOneDefaultBusinessHour();
|
||||
if (!defaultBH) {
|
||||
throw new Error('Default business hour not found');
|
||||
}
|
||||
await this.UsersRepository.closeAgentsBusinessHoursByBusinessHourIds([businessHour._id, defaultBH._id]);
|
||||
|
||||
// If i'm the only one, disable the business hour
|
||||
const imTheOnlyOne = !(await LivechatDepartment.countByBusinessHourIdExcludingDepartmentId(businessHour._id, department._id));
|
||||
if (imTheOnlyOne) {
|
||||
bhLogger.warn({
|
||||
msg: 'onDepartmentDisabled: department is the only one on business hour, disabling it',
|
||||
departmentId: department._id,
|
||||
businessHourId: businessHour._id,
|
||||
});
|
||||
await this.BusinessHourRepository.disableBusinessHour(businessHour._id);
|
||||
|
||||
businessHour = await this.BusinessHourRepository.findOneById(department.businessHourId);
|
||||
if (!businessHour) {
|
||||
throw new Error(`Business hour ${department.businessHourId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// start default business hour and this BH if needed
|
||||
if (!settings.get('Livechat_enable_business_hours')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([businessHour, defaultBH]);
|
||||
for await (const bh of businessHourToOpen) {
|
||||
await openBusinessHour(bh, false);
|
||||
}
|
||||
|
||||
await makeAgentsUnavailableBasedOnBusinessHour();
|
||||
await businessHourManager.restartCronJobsIfNecessary();
|
||||
}
|
||||
|
||||
async onDepartmentArchived(department: Pick<ILivechatDepartment, '_id' | 'businessHourId'>): Promise<void> {
|
||||
bhLogger.debug({ msg: 'Processing department archived event on multiple business hours', department });
|
||||
return this.onDepartmentDisabled(department);
|
||||
}
|
||||
|
||||
override allowAgentChangeServiceStatus(agentId: string): Promise<boolean> {
|
||||
return this.UsersRepository.isAgentWithinBusinessHours(agentId);
|
||||
}
|
||||
|
||||
async onNewAgentCreated(agentId: string): Promise<void> {
|
||||
await this.applyAnyOpenBusinessHourToAgent(agentId);
|
||||
|
||||
await makeAgentsUnavailableBasedOnBusinessHour([agentId]);
|
||||
}
|
||||
|
||||
private async applyAnyOpenBusinessHourToAgent(agentId: string): Promise<void> {
|
||||
const currentTime = moment().utc();
|
||||
const day = currentTime.format('dddd');
|
||||
const allActiveBusinessHoursForEntireWeek = await this.BusinessHourRepository.findActiveBusinessHours({
|
||||
projection: {
|
||||
workHours: 1,
|
||||
timezone: 1,
|
||||
type: 1,
|
||||
active: 1,
|
||||
},
|
||||
});
|
||||
const openedBusinessHours = await filterBusinessHoursThatMustBeOpenedByDay(allActiveBusinessHoursForEntireWeek, day);
|
||||
if (!openedBusinessHours.length) {
|
||||
bhLogger.debug({
|
||||
msg: 'Business hour status check failed for agent. No opened business hour found for the current day',
|
||||
agentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const agentDepartments = await LivechatDepartmentAgents.find(
|
||||
{ departmentEnabled: true, agentId },
|
||||
{ projection: { agentId: 1, departmentId: 1 } },
|
||||
).toArray();
|
||||
|
||||
if (!agentDepartments.length) {
|
||||
// check if default businessHour is active
|
||||
const isDefaultBHActive = openedBusinessHours.find(({ type }) => type === LivechatBusinessHourTypes.DEFAULT);
|
||||
if (isDefaultBHActive?._id) {
|
||||
await Users.openAgentBusinessHoursByBusinessHourIdsAndAgentId([isDefaultBHActive._id], agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
bhLogger.debug({
|
||||
msg: 'Business hour status check failed for agent. Found default business hour to be inactive',
|
||||
agentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// check if any one these departments have a opened business hour linked to it
|
||||
const departments = (await LivechatDepartment.findInIds(
|
||||
agentDepartments.map(({ departmentId }) => departmentId),
|
||||
{ projection: { _id: 1, businessHourId: 1 } },
|
||||
).toArray()) as Pick<ILivechatDepartment, '_id' | 'businessHourId'>[];
|
||||
|
||||
const departmentsWithActiveBH = departments.filter(
|
||||
({ businessHourId }) => businessHourId && openedBusinessHours.findIndex(({ _id }) => _id === businessHourId) !== -1,
|
||||
);
|
||||
|
||||
if (!departmentsWithActiveBH.length) {
|
||||
// No opened business hour found for any of the departments connected to the agent
|
||||
// check if this agent has any departments that is connected to any non-default business hour
|
||||
// if no such departments found then check default BH and if it is active, then allow the agent to change service status
|
||||
const hasAtLeastOneDepartmentWithNonDefaultBH = departments.some(({ businessHourId }) => {
|
||||
// check if business hour is active
|
||||
return businessHourId && allActiveBusinessHoursForEntireWeek.findIndex(({ _id }) => _id === businessHourId) !== -1;
|
||||
});
|
||||
if (!hasAtLeastOneDepartmentWithNonDefaultBH) {
|
||||
const isDefaultBHActive = openedBusinessHours.find(({ type }) => type === LivechatBusinessHourTypes.DEFAULT);
|
||||
if (isDefaultBHActive?._id) {
|
||||
await Users.openAgentBusinessHoursByBusinessHourIdsAndAgentId([isDefaultBHActive._id], agentId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
bhLogger.debug({
|
||||
msg: 'Business hour status check failed for agent. No opened business hour found for any of the departments connected to the agent',
|
||||
agentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeBusinessHoursForAgent = departmentsWithActiveBH.map(({ businessHourId }) => businessHourId).filter(isTruthy);
|
||||
await Users.openAgentBusinessHoursByBusinessHourIdsAndAgentId(activeBusinessHoursForAgent, agentId);
|
||||
|
||||
bhLogger.debug({
|
||||
msg: `Business hour status check passed for agent. Found opened business hour for departments connected to the agent`,
|
||||
activeBusinessHoursForAgent,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRemoveAgentsFromDepartments(department: Record<string, any>, agentsIds: string[], options: any): Promise<any> {
|
||||
const agentIdsWithoutDepartment: string[] = [];
|
||||
const agentIdsToRemoveCurrentBusinessHour: string[] = [];
|
||||
|
||||
const [agentsWithDepartment, [agentsOfDepartment] = []] = await Promise.all([
|
||||
LivechatDepartmentAgents.findByAgentIds(agentsIds, { projection: { agentId: 1 } }).toArray(),
|
||||
...[department?.businessHourId ? LivechatDepartment.findAgentsByBusinessHourId(department.businessHourId).toArray() : []],
|
||||
]);
|
||||
|
||||
for (const agentId of agentsIds) {
|
||||
if (!agentsWithDepartment.find((agent) => agent.agentId === agentId)) {
|
||||
agentIdsWithoutDepartment.push(agentId);
|
||||
}
|
||||
if (!agentsOfDepartment?.agentIds?.find((agent) => agent === agentId)) {
|
||||
agentIdsToRemoveCurrentBusinessHour.push(agentId);
|
||||
}
|
||||
}
|
||||
if (department.businessHourId) {
|
||||
await removeBusinessHourByAgentIds(agentIdsToRemoveCurrentBusinessHour, department.businessHourId);
|
||||
}
|
||||
if (!agentIdsWithoutDepartment.length) {
|
||||
return options;
|
||||
}
|
||||
const defaultBusinessHour = await this.BusinessHourRepository.findOneDefaultBusinessHour();
|
||||
if (!defaultBusinessHour) {
|
||||
return options;
|
||||
}
|
||||
const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([defaultBusinessHour]);
|
||||
if (!businessHourToOpen.length) {
|
||||
return options;
|
||||
}
|
||||
await this.UsersRepository.addBusinessHourByAgentIds(agentIdsWithoutDepartment, defaultBusinessHour._id);
|
||||
return options;
|
||||
}
|
||||
|
||||
private async openBusinessHour(businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>): Promise<void> {
|
||||
return openBusinessHour(businessHour);
|
||||
}
|
||||
|
||||
private async closeBusinessHour(businessHour: Pick<ILivechatBusinessHour, '_id' | 'type'>): Promise<void> {
|
||||
await closeBusinessHour(businessHour);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import './Custom';
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ILivechatBusinessHour } from '@rocket.chat/core-typings';
|
||||
import { LivechatBusinessHours, LivechatDepartment } from '@rocket.chat/models';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
|
||||
import { hasPermissionAsync } from '../../../../../../server/lib/authorization/hasPermission';
|
||||
import type { IPaginatedResponse, IPagination } from '../../../../api/v1/omnichannel/lib/definition';
|
||||
|
||||
interface IResponse extends IPaginatedResponse {
|
||||
businessHours: ILivechatBusinessHour[];
|
||||
}
|
||||
|
||||
export async function findBusinessHours(userId: string, { offset, count, sort }: IPagination, name?: string): Promise<IResponse> {
|
||||
if (!(await hasPermissionAsync(userId, 'view-livechat-business-hours'))) {
|
||||
throw new Error('error-not-authorized');
|
||||
}
|
||||
const query = {};
|
||||
if (name) {
|
||||
const filterReg = new RegExp(escapeRegExp(name), 'i');
|
||||
Object.assign(query, { name: filterReg });
|
||||
}
|
||||
const { cursor, totalCount } = LivechatBusinessHours.findPaginated(query, {
|
||||
sort: sort || { name: 1 },
|
||||
skip: offset,
|
||||
limit: count,
|
||||
});
|
||||
|
||||
const [businessHours, total] = await Promise.all([cursor.toArray(), totalCount]);
|
||||
|
||||
// add departments to businessHours
|
||||
const businessHoursWithDepartments = await Promise.all(
|
||||
businessHours.map(async (businessHour) => {
|
||||
const currentDepartments = await LivechatDepartment.findByBusinessHourId(businessHour._id, {
|
||||
projection: { _id: 1, name: 1 },
|
||||
}).toArray();
|
||||
|
||||
if (currentDepartments.length) {
|
||||
businessHour.departments = currentDepartments;
|
||||
}
|
||||
|
||||
return businessHour;
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
businessHours: businessHoursWithDepartments,
|
||||
count: businessHours.length,
|
||||
offset,
|
||||
total,
|
||||
};
|
||||
}
|
||||
23
apps/meteor/ee/server/lib/omnichannel/debounceByParams.ts
Normal file
23
apps/meteor/ee/server/lib/omnichannel/debounceByParams.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import debounce from 'lodash.debounce';
|
||||
import mem from 'mem';
|
||||
|
||||
interface IMemoizeDebouncedFunction<F extends (...args: any[]) => any> {
|
||||
(...args: Parameters<F>): void;
|
||||
flush: (...args: Parameters<F>) => void;
|
||||
}
|
||||
|
||||
// Debounce `func` based on passed parameters
|
||||
// ref: https://github.com/lodash/lodash/issues/2403#issuecomment-816137402
|
||||
export function memoizeDebounce<F extends (...args: any[]) => any>(func: F, wait = 0, options: any = {}): IMemoizeDebouncedFunction<F> {
|
||||
const debounceMemo = mem((..._args: Parameters<F>) => debounce(func, wait, options));
|
||||
|
||||
function wrappedFunction(this: IMemoizeDebouncedFunction<F>, ...args: Parameters<F>): ReturnType<F> | undefined {
|
||||
return debounceMemo(...args)(...args);
|
||||
}
|
||||
|
||||
wrappedFunction.flush = (...args: Parameters<F>): void => {
|
||||
debounceMemo(...args).flush();
|
||||
};
|
||||
|
||||
return wrappedFunction as unknown as IMemoizeDebouncedFunction<F>;
|
||||
}
|
||||
39
apps/meteor/ee/server/lib/omnichannel/index.ts
Normal file
39
apps/meteor/ee/server/lib/omnichannel/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { patchOmniCore } from '@rocket.chat/omni-core-ee';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import '../../hooks/omnichannel/afterTakeInquiry';
|
||||
import '../../hooks/omnichannel/beforeNewInquiry';
|
||||
import '../../hooks/omnichannel/beforeNewRoom';
|
||||
import '../../hooks/omnichannel/beforeRoutingChat';
|
||||
import '../../hooks/omnichannel/checkAgentBeforeTakeInquiry';
|
||||
import '../../hooks/omnichannel/handleNextAgentPreferredEvents';
|
||||
import '../../hooks/omnichannel/onCheckRoomParamsApi';
|
||||
import '../../hooks/omnichannel/onLoadConfigApi';
|
||||
import '../../hooks/omnichannel/onSaveVisitorInfo';
|
||||
import '../../hooks/omnichannel/scheduleAutoTransfer';
|
||||
import '../../hooks/omnichannel/resumeOnHold';
|
||||
import '../../hooks/omnichannel/afterOnHold';
|
||||
import '../../hooks/omnichannel/onTransferFailure';
|
||||
import './routing/LoadBalancing';
|
||||
import './routing/LoadRotation';
|
||||
import './AutoCloseOnHoldScheduler';
|
||||
import './business-hour';
|
||||
import '../../api/v1/omnichannel';
|
||||
import { createDefaultPriorities } from './priorities';
|
||||
|
||||
patchOmniCore();
|
||||
|
||||
await License.onLicense('livechat-enterprise', async () => {
|
||||
require('../../hooks/omnichannel');
|
||||
await import('./startup');
|
||||
const { createPermissions } = await import('./permissions');
|
||||
const { createSettings } = await import('./settings');
|
||||
await import('./unit');
|
||||
|
||||
Meteor.startup(() => {
|
||||
void createSettings();
|
||||
void createPermissions();
|
||||
void createDefaultPriorities();
|
||||
});
|
||||
});
|
||||
11
apps/meteor/ee/server/lib/omnichannel/logger.ts
Normal file
11
apps/meteor/ee/server/lib/omnichannel/logger.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('LivechatEnterprise');
|
||||
|
||||
export const queriesLogger = logger.section('Queries');
|
||||
export const helperLogger = logger.section('Helper');
|
||||
export const cbLogger = logger.section('Callbacks');
|
||||
export const bhLogger = logger.section('Business-Hours');
|
||||
export const contactLogger = logger.section('Contacts');
|
||||
|
||||
export const schedulerLogger = new Logger('Scheduler');
|
||||
331
apps/meteor/ee/server/lib/omnichannel/outboundcomms/rest.ts
Normal file
331
apps/meteor/ee/server/lib/omnichannel/outboundcomms/rest.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import type { IOutboundMessage, IOutboundProvider, IOutboundProviderMetadata, ValidOutboundProvider } from '@rocket.chat/core-typings';
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import type { OutboundCommsEndpoints } from '../../../api/v1/omnichannel/outbound';
|
||||
|
||||
declare module '@rocket.chat/rest-typings' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
||||
interface Endpoints extends OutboundCommsEndpoints {}
|
||||
}
|
||||
|
||||
type GenericErrorResponse = { success: boolean; message: string };
|
||||
|
||||
type GETOutboundProviderParams = { type?: ValidOutboundProvider };
|
||||
const GETOutboundProviderSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['phone', 'email'],
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
};
|
||||
export const GETOutboundProviderParamsSchema = ajv.compile<GETOutboundProviderParams>(GETOutboundProviderSchema);
|
||||
|
||||
const GETOutboundProvidersResponse = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
providers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
providerId: {
|
||||
type: 'string',
|
||||
},
|
||||
providerName: {
|
||||
type: 'string',
|
||||
},
|
||||
supportsTemplates: {
|
||||
type: 'boolean',
|
||||
},
|
||||
providerType: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export const GETOutboundProvidersResponseSchema = ajv.compile<{ providers: IOutboundProvider[] }>(GETOutboundProvidersResponse);
|
||||
|
||||
const GETOutboundProviderBadRequestError = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
export const GETOutboundProviderBadRequestErrorSchema = ajv.compile<GenericErrorResponse>(GETOutboundProviderBadRequestError);
|
||||
|
||||
type POSTOutboundMessageParamsType = IOutboundMessage;
|
||||
|
||||
const POSTOutboundMessageSchema = {
|
||||
type: 'object',
|
||||
required: ['to', 'type'],
|
||||
properties: {
|
||||
to: { type: 'string', minLength: 1 },
|
||||
type: { type: 'string' },
|
||||
templateProviderPhoneNumber: { type: 'string' },
|
||||
template: {
|
||||
type: 'object',
|
||||
required: ['name', 'language'],
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
language: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
properties: {
|
||||
code: { type: 'string' },
|
||||
policy: {
|
||||
type: 'string',
|
||||
enum: ['deterministic', 'fallback'],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
components: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['type', 'parameters'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['header', 'body', 'footer', 'button'],
|
||||
},
|
||||
parameters: {
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
required: ['type', 'text'],
|
||||
properties: {
|
||||
type: { const: 'text' },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
required: ['type', 'currency'],
|
||||
properties: {
|
||||
type: { const: 'currency' },
|
||||
currency: {
|
||||
type: 'object',
|
||||
required: ['fallbackValue', 'code', 'amount1000'],
|
||||
properties: {
|
||||
fallbackValue: { type: 'string' },
|
||||
code: { type: 'string' },
|
||||
amount1000: { type: 'number' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
required: ['type', 'dateTime'],
|
||||
properties: {
|
||||
type: { const: 'date_time' },
|
||||
dateTime: {
|
||||
type: 'object',
|
||||
required: ['fallbackValue'],
|
||||
properties: {
|
||||
fallbackValue: { type: 'string' },
|
||||
timestamp: { type: 'number' },
|
||||
dayOfWeek: { type: 'number' },
|
||||
dayOfMonth: { type: 'number' },
|
||||
year: { type: 'number' },
|
||||
month: { type: 'number' },
|
||||
hour: { type: 'number' },
|
||||
minute: { type: 'number' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
required: ['type', 'link'],
|
||||
properties: {
|
||||
type: { const: 'media' },
|
||||
link: { type: 'string' },
|
||||
format: { type: 'string', enum: ['image', 'video', 'document'] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
namespace: { type: 'string' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
agentId: { type: 'string' },
|
||||
departmentId: { type: 'string' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const POSTOutboundMessageParams = ajv.compile<POSTOutboundMessageParamsType>(POSTOutboundMessageSchema);
|
||||
|
||||
const POSTOutboundMessageSuccess = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const POSTOutboundMessageSuccessSchema = ajv.compile<void>(POSTOutboundMessageSuccess);
|
||||
|
||||
const OutboundProviderMetadataSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
metadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
providerId: {
|
||||
type: 'string',
|
||||
},
|
||||
providerName: {
|
||||
type: 'string',
|
||||
},
|
||||
supportsTemplates: {
|
||||
type: 'boolean',
|
||||
},
|
||||
providerType: {
|
||||
type: 'string',
|
||||
enum: ['phone', 'email'],
|
||||
},
|
||||
templates: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
language: {
|
||||
type: 'string',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
},
|
||||
qualityScore: {
|
||||
type: 'object',
|
||||
required: ['score', 'reasons'],
|
||||
properties: {
|
||||
score: {
|
||||
type: 'string',
|
||||
enum: ['GREEN', 'YELLOW', 'RED', 'UNKNOWN'],
|
||||
},
|
||||
reasons: {
|
||||
type: ['array', 'null'],
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
type: { const: 'header' },
|
||||
format: {
|
||||
type: 'string',
|
||||
enum: ['text', 'image', 'video', 'document'],
|
||||
},
|
||||
text: { type: 'string' },
|
||||
example: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
headerText: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
type: { const: 'body' },
|
||||
text: { type: 'string' },
|
||||
example: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bodyText: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['type', 'text'],
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
type: { const: 'footer' },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['type', 'text'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
createdAt: { type: 'string' },
|
||||
createdBy: { type: 'string' },
|
||||
modifiedAt: { type: 'string' },
|
||||
modifiedBy: { type: 'string' },
|
||||
namespace: { type: 'string' },
|
||||
wabaAccountId: { type: 'string' },
|
||||
phoneNumber: { type: 'string' },
|
||||
partnerId: { type: 'string' },
|
||||
externalId: { type: 'string' },
|
||||
updatedExternal: { type: 'string' },
|
||||
rejectedReason: {
|
||||
type: ['string', 'null'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GETOutboundProviderMetadataSchema = ajv.compile<{ metadata: IOutboundProviderMetadata }>(OutboundProviderMetadataSchema);
|
||||
36
apps/meteor/ee/server/lib/omnichannel/permissions.ts
Normal file
36
apps/meteor/ee/server/lib/omnichannel/permissions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Permissions, Roles } from '@rocket.chat/models';
|
||||
|
||||
import { createOrUpdateProtectedRoleAsync } from '../../../../server/lib/roles/createOrUpdateProtectedRole';
|
||||
|
||||
const livechatMonitorRole = 'livechat-monitor';
|
||||
const livechatManagerRole = 'livechat-manager';
|
||||
const adminRole = 'admin';
|
||||
const livechatAgentRole = 'livechat-agent';
|
||||
|
||||
export const omnichannelEEPermissions = [
|
||||
{ _id: 'manage-livechat-units', roles: [adminRole, livechatManagerRole] },
|
||||
{ _id: 'manage-livechat-monitors', roles: [adminRole, livechatManagerRole] },
|
||||
{ _id: 'manage-livechat-tags', roles: [adminRole, livechatManagerRole] },
|
||||
{ _id: 'manage-livechat-priorities', roles: [adminRole, livechatManagerRole] },
|
||||
{ _id: 'manage-livechat-sla', roles: [adminRole, livechatManagerRole] },
|
||||
{ _id: 'manage-livechat-canned-responses', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
|
||||
{ _id: 'request-pdf-transcript', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
|
||||
{ _id: 'view-livechat-reports', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
|
||||
{ _id: 'block-livechat-contact', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
|
||||
{ _id: 'unblock-livechat-contact', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
|
||||
{ _id: 'outbound.send-messages', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
|
||||
{ _id: 'outbound.can-assign-queues', roles: [adminRole, livechatManagerRole] },
|
||||
{ _id: 'outbound.can-assign-any-agent', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
|
||||
{ _id: 'outbound.can-assign-self-only', roles: [livechatAgentRole] },
|
||||
];
|
||||
|
||||
export const createPermissions = async (): Promise<void> => {
|
||||
const monitorRole = await Roles.findOneById(livechatMonitorRole, { projection: { _id: 1 } });
|
||||
if (!monitorRole) {
|
||||
await createOrUpdateProtectedRoleAsync(livechatMonitorRole, {
|
||||
name: livechatMonitorRole,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(omnichannelEEPermissions.map(async (permission) => Permissions.create(permission._id, permission.roles)));
|
||||
};
|
||||
39
apps/meteor/ee/server/lib/omnichannel/priorities.ts
Normal file
39
apps/meteor/ee/server/lib/omnichannel/priorities.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ILivechatPriority } from '@rocket.chat/core-typings';
|
||||
import { LivechatPriorityWeight } from '@rocket.chat/core-typings';
|
||||
import { LivechatPriority } from '@rocket.chat/models';
|
||||
|
||||
const defaultPriorities: Omit<ILivechatPriority, '_id' | '_updatedAt'>[] = [
|
||||
{
|
||||
i18n: 'Lowest',
|
||||
sortItem: LivechatPriorityWeight.LOWEST,
|
||||
dirty: false,
|
||||
},
|
||||
{
|
||||
i18n: 'Low',
|
||||
sortItem: LivechatPriorityWeight.LOW,
|
||||
dirty: false,
|
||||
},
|
||||
{
|
||||
i18n: 'Medium',
|
||||
sortItem: LivechatPriorityWeight.MEDIUM,
|
||||
dirty: false,
|
||||
},
|
||||
{
|
||||
i18n: 'High',
|
||||
sortItem: LivechatPriorityWeight.HIGH,
|
||||
dirty: false,
|
||||
},
|
||||
{
|
||||
i18n: 'Highest',
|
||||
sortItem: LivechatPriorityWeight.HIGHEST,
|
||||
dirty: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const createDefaultPriorities = async (): Promise<void> => {
|
||||
const priorities = await LivechatPriority.estimatedDocumentCount();
|
||||
|
||||
if (!priorities) {
|
||||
await LivechatPriority.insertMany(defaultPriorities);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { OmnichannelTranscript, QueueWorker } from '@rocket.chat/core-services';
|
||||
import type { AtLeast, IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import ExpiryMap from 'expiry-map';
|
||||
|
||||
import { logger } from './logger';
|
||||
|
||||
// Allow to request a transcript again after 15 seconds, assuming the first one didn't complete
|
||||
// This won't prevent multiple transcript generated for the same room in a multi-instance deployment since state is not shared, but we're ok with the drawbacks
|
||||
const LockMap = new ExpiryMap<string, boolean>(15000);
|
||||
|
||||
const serviceName = 'omnichannel-transcript' as const;
|
||||
export const requestPdfTranscript = async (
|
||||
room: AtLeast<IOmnichannelRoom, '_id' | 'open' | 'v' | 'pdfTranscriptFileId'>,
|
||||
requestedBy: string,
|
||||
): Promise<void> => {
|
||||
if (room.open) {
|
||||
throw new Error('room-still-open');
|
||||
}
|
||||
|
||||
if (!room.v) {
|
||||
throw new Error('improper-room-state');
|
||||
}
|
||||
|
||||
// Don't request a transcript if there's already one requested
|
||||
if (LockMap.has(room._id) || room.pdfTranscriptFileId) {
|
||||
// TODO: use logger
|
||||
logger.info({ msg: `Transcript already requested`, roomId: room._id });
|
||||
return;
|
||||
}
|
||||
|
||||
LockMap.set(room._id, true);
|
||||
|
||||
const details = { details: { rid: room._id, userId: requestedBy, from: serviceName } };
|
||||
// Make the whole process sync when running on test mode
|
||||
// This will prevent the usage of timeouts on the tests of this functionality :)
|
||||
if (process.env.TEST_MODE) {
|
||||
await OmnichannelTranscript.workOnPdf(details);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ msg: 'Queuing work for room', roomId: room._id });
|
||||
await QueueWorker.queueWork('work', `${serviceName}.workOnPdf`, details);
|
||||
};
|
||||
52
apps/meteor/ee/server/lib/omnichannel/restrictQuery.ts
Normal file
52
apps/meteor/ee/server/lib/omnichannel/restrictQuery.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatDepartment } from '@rocket.chat/models';
|
||||
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
|
||||
import type { FilterOperators } from 'mongodb';
|
||||
|
||||
import { cbLogger } from './logger';
|
||||
|
||||
export const restrictQuery = async ({
|
||||
originalQuery = {},
|
||||
unitsFilter,
|
||||
userId,
|
||||
}: {
|
||||
originalQuery?: FilterOperators<IOmnichannelRoom>;
|
||||
unitsFilter?: string[];
|
||||
userId?: string;
|
||||
}) => {
|
||||
const query = { ...originalQuery };
|
||||
|
||||
let userUnits = await getUnitsFromUser(userId);
|
||||
if (!Array.isArray(userUnits)) {
|
||||
if (Array.isArray(unitsFilter) && unitsFilter.length) {
|
||||
return { ...query, departmentAncestors: { $in: unitsFilter } };
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
if (Array.isArray(unitsFilter) && unitsFilter.length) {
|
||||
const userUnit = new Set([...userUnits]);
|
||||
const filteredUnits = new Set(unitsFilter);
|
||||
|
||||
// IF user is trying to filter by a unit he doens't have access to, apply empty filter (no matches)
|
||||
userUnits = [...userUnit.intersection(filteredUnits)];
|
||||
}
|
||||
|
||||
const departments = await LivechatDepartment.find(
|
||||
{ $or: [{ ancestors: { $in: userUnits } }, { _id: { $in: userUnits } }] },
|
||||
{ projection: { _id: 1 } },
|
||||
).toArray();
|
||||
|
||||
const expressions = query.$and || [];
|
||||
const condition = {
|
||||
$or: [
|
||||
{ departmentAncestors: { $in: userUnits } },
|
||||
{ departmentId: { $in: departments.map(({ _id }) => _id) } },
|
||||
{ departmentId: { $exists: false } },
|
||||
],
|
||||
};
|
||||
query.$and = [condition, ...expressions];
|
||||
|
||||
cbLogger.debug({ msg: 'Applying room query restrictions', userUnits, query });
|
||||
return query;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface IRoutingManagerConfig {
|
||||
previewRoom: boolean;
|
||||
showConnecting: boolean;
|
||||
showQueue: boolean;
|
||||
showQueueLink: boolean;
|
||||
returnQueue: boolean;
|
||||
enableTriggerAction: boolean;
|
||||
autoAssignAgent: boolean;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import type { IRoutingManagerConfig } from './IRoutingManagerConfig';
|
||||
import { RoutingManager } from '../../../../../server/lib/omnichannel/RoutingManager';
|
||||
import { settings } from '../../../../../server/settings';
|
||||
import { getChatLimitsQuery } from '../../../hooks/omnichannel/applySimultaneousChatsRestrictions';
|
||||
import { logger } from '../logger';
|
||||
|
||||
/* Load Balancing Queuing method:
|
||||
*
|
||||
* default method where the agent with the least number
|
||||
* of open chats is paired with the incoming livechat
|
||||
*/
|
||||
class LoadBalancing {
|
||||
private _config: IRoutingManagerConfig;
|
||||
|
||||
constructor() {
|
||||
this._config = {
|
||||
previewRoom: false,
|
||||
showConnecting: false,
|
||||
showQueue: false,
|
||||
showQueueLink: false,
|
||||
returnQueue: false,
|
||||
enableTriggerAction: true,
|
||||
autoAssignAgent: true,
|
||||
};
|
||||
}
|
||||
|
||||
get config(): IRoutingManagerConfig {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
async getNextAgent(department?: string, ignoreAgentId?: string) {
|
||||
const enabledWhenIdle = settings.get<boolean>('Livechat_enabled_when_agent_idle');
|
||||
const enabledWhenOffline = settings.get<boolean>('Livechat_accept_chats_with_no_agents');
|
||||
|
||||
const extraQuery = await getChatLimitsQuery(department);
|
||||
const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle, enabledWhenOffline);
|
||||
logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle, enabledWhenOffline });
|
||||
|
||||
const nextAgent = await Users.getNextLeastBusyAgent(
|
||||
department,
|
||||
ignoreAgentId,
|
||||
enabledWhenIdle,
|
||||
unavailableUsers.map((u) => u.username),
|
||||
enabledWhenOffline,
|
||||
);
|
||||
if (!nextAgent) {
|
||||
return;
|
||||
}
|
||||
const { agentId, username } = nextAgent;
|
||||
return { agentId, username };
|
||||
}
|
||||
}
|
||||
|
||||
RoutingManager.registerMethod('Load_Balancing', LoadBalancing);
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { IOmnichannelCustomAgent } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import type { IRoutingManagerConfig } from './IRoutingManagerConfig';
|
||||
import { RoutingManager } from '../../../../../server/lib/omnichannel/RoutingManager';
|
||||
import { settings } from '../../../../../server/settings';
|
||||
import { getChatLimitsQuery } from '../../../hooks/omnichannel/applySimultaneousChatsRestrictions';
|
||||
import { logger } from '../logger';
|
||||
|
||||
/* Load Rotation Queuing method:
|
||||
* Routing method where the agent with the oldest routing time is the next agent to serve incoming chats
|
||||
*/
|
||||
class LoadRotation {
|
||||
private _config: IRoutingManagerConfig;
|
||||
|
||||
constructor() {
|
||||
this._config = {
|
||||
previewRoom: false,
|
||||
showConnecting: false,
|
||||
showQueue: false,
|
||||
showQueueLink: false,
|
||||
returnQueue: false,
|
||||
enableTriggerAction: true,
|
||||
autoAssignAgent: true,
|
||||
};
|
||||
}
|
||||
|
||||
get config(): IRoutingManagerConfig {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
public async getNextAgent(department?: string, ignoreAgentId?: string): Promise<IOmnichannelCustomAgent | undefined> {
|
||||
const enabledWhenIdle = settings.get<boolean>('Livechat_enabled_when_agent_idle');
|
||||
const enabledWhenOffline = settings.get<boolean>('Livechat_accept_chats_with_no_agents');
|
||||
|
||||
const extraQuery = await getChatLimitsQuery(department);
|
||||
const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle, enabledWhenOffline);
|
||||
logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle, enabledWhenOffline });
|
||||
|
||||
const nextAgent = await Users.getLastAvailableAgentRouted(
|
||||
department,
|
||||
ignoreAgentId,
|
||||
enabledWhenIdle,
|
||||
unavailableUsers.map((user) => user.username),
|
||||
enabledWhenOffline,
|
||||
);
|
||||
if (!nextAgent?.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { agentId, username } = nextAgent;
|
||||
return { agentId, username };
|
||||
}
|
||||
}
|
||||
|
||||
RoutingManager.registerMethod('Load_Rotation', LoadRotation);
|
||||
345
apps/meteor/ee/server/lib/omnichannel/settings.ts
Normal file
345
apps/meteor/ee/server/lib/omnichannel/settings.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { OmnichannelSortingMechanismSettingType } from '@rocket.chat/core-typings';
|
||||
import { Settings } from '@rocket.chat/models';
|
||||
|
||||
import { settingsRegistry } from '../../../../server/settings';
|
||||
|
||||
const omnichannelEnabledQuery = { _id: 'Livechat_enabled', value: true };
|
||||
const businessHoursEnabled = { _id: 'Livechat_enable_business_hours', value: true };
|
||||
|
||||
export const createSettings = async (): Promise<void> => {
|
||||
await settingsRegistry.add('Livechat_abandoned_rooms_action', 'none', {
|
||||
type: 'select',
|
||||
group: 'Omnichannel',
|
||||
section: 'Sessions',
|
||||
values: [
|
||||
{ key: 'none', i18nLabel: 'Do_Nothing' },
|
||||
{ key: 'close', i18nLabel: 'Livechat_close_chat' },
|
||||
{ key: 'on-hold', i18nLabel: 'Omnichannel_onHold_Chat' },
|
||||
],
|
||||
enterprise: true,
|
||||
public: true,
|
||||
invalidValue: 'none',
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_abandoned_rooms_closed_custom_message', '', {
|
||||
type: 'string',
|
||||
group: 'Omnichannel',
|
||||
section: 'Sessions',
|
||||
i18nLabel: 'Livechat_abandoned_rooms_closed_custom_message',
|
||||
enableQuery: [{ _id: 'Livechat_abandoned_rooms_action', value: 'close' }, omnichannelEnabledQuery],
|
||||
enterprise: true,
|
||||
invalidValue: '',
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Omnichannel_max_fallback_forward_depth', 3, {
|
||||
type: 'int',
|
||||
group: 'Omnichannel',
|
||||
section: 'Routing',
|
||||
i18nLabel: 'Omnichannel_max_fallback_forward_depth',
|
||||
enterprise: true,
|
||||
invalidValue: 0,
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_last_chatted_agent_routing', false, {
|
||||
type: 'boolean',
|
||||
group: 'Omnichannel',
|
||||
section: 'Routing',
|
||||
enterprise: true,
|
||||
invalidValue: false,
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
|
||||
await settingsRegistry.addGroup('Omnichannel', async function () {
|
||||
await this.section('Business_Hours', async function () {
|
||||
await this.add('Livechat_business_hour_type', 'Single', {
|
||||
type: 'select',
|
||||
values: [
|
||||
{
|
||||
key: 'Single',
|
||||
i18nLabel: 'Single',
|
||||
},
|
||||
{
|
||||
key: 'Multiple',
|
||||
i18nLabel: 'Multiple',
|
||||
},
|
||||
],
|
||||
public: true,
|
||||
i18nLabel: 'Livechat_business_hour_type',
|
||||
enterprise: true,
|
||||
invalidValue: 'Single',
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: [omnichannelEnabledQuery, businessHoursEnabled],
|
||||
});
|
||||
});
|
||||
|
||||
await this.section('Queue_management', async function () {
|
||||
await this.add('Livechat_waiting_queue', false, {
|
||||
type: 'boolean',
|
||||
group: 'Omnichannel',
|
||||
section: 'Queue_management',
|
||||
i18nLabel: 'Waiting_queue',
|
||||
enterprise: true,
|
||||
invalidValue: false,
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
public: true,
|
||||
});
|
||||
|
||||
await this.add('Livechat_waiting_queue_message', '', {
|
||||
type: 'string',
|
||||
group: 'Omnichannel',
|
||||
section: 'Queue_management',
|
||||
i18nLabel: 'Waiting_queue_message',
|
||||
i18nDescription: 'Waiting_queue_message_description',
|
||||
enableQuery: [{ _id: 'Livechat_waiting_queue', value: true }, omnichannelEnabledQuery],
|
||||
enterprise: true,
|
||||
invalidValue: '',
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await this.add('Livechat_maximum_chats_per_agent', 0, {
|
||||
type: 'int',
|
||||
group: 'Omnichannel',
|
||||
section: 'Queue_management',
|
||||
i18nLabel: 'Max_number_of_chats_per_agent',
|
||||
i18nDescription: 'Max_number_of_chats_per_agent_description',
|
||||
enableQuery: [{ _id: 'Livechat_waiting_queue', value: true }, omnichannelEnabledQuery],
|
||||
enterprise: true,
|
||||
invalidValue: 0,
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await this.add('Omnichannel_calculate_dispatch_service_queue_statistics', true, {
|
||||
type: 'boolean',
|
||||
group: 'Omnichannel',
|
||||
section: 'Queue_management',
|
||||
i18nLabel: 'Omnichannel_calculate_dispatch_service_queue_statistics',
|
||||
enableQuery: [{ _id: 'Livechat_waiting_queue', value: true }, omnichannelEnabledQuery],
|
||||
enterprise: true,
|
||||
invalidValue: false,
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await this.add('Livechat_number_most_recent_chats_estimate_wait_time', 100, {
|
||||
type: 'int',
|
||||
group: 'Omnichannel',
|
||||
section: 'Queue_management',
|
||||
i18nLabel: 'Number_of_most_recent_chats_estimate_wait_time',
|
||||
i18nDescription: 'Number_of_most_recent_chats_estimate_wait_time_description',
|
||||
enableQuery: [{ _id: 'Livechat_waiting_queue', value: true }, omnichannelEnabledQuery],
|
||||
enterprise: true,
|
||||
invalidValue: 100,
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await this.add('Livechat_max_queue_wait_time', -1, {
|
||||
type: 'int',
|
||||
group: 'Omnichannel',
|
||||
section: 'Queue_management',
|
||||
i18nLabel: 'Livechat_maximum_queue_wait_time',
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
i18nDescription: 'Livechat_maximum_queue_wait_time_description',
|
||||
enterprise: true,
|
||||
invalidValue: -1,
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await this.add('Omnichannel_sorting_mechanism', 'Timestamp', {
|
||||
type: 'select',
|
||||
values: [
|
||||
{ key: OmnichannelSortingMechanismSettingType.Timestamp, i18nLabel: 'Timestamp' },
|
||||
{ key: OmnichannelSortingMechanismSettingType.Priority, i18nLabel: 'Priorities' },
|
||||
{ key: OmnichannelSortingMechanismSettingType.SLAs, i18nLabel: 'SLA_Policies' },
|
||||
],
|
||||
group: 'Omnichannel',
|
||||
section: 'Queue_management',
|
||||
i18nLabel: 'Sorting_mechanism',
|
||||
enableQuery: [omnichannelEnabledQuery],
|
||||
enterprise: true,
|
||||
public: true,
|
||||
modules: ['livechat-enterprise'],
|
||||
invalidValue: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_AdditionalWidgetScripts', '', {
|
||||
type: 'string',
|
||||
group: 'Omnichannel',
|
||||
section: 'Livechat',
|
||||
enterprise: true,
|
||||
invalidValue: '',
|
||||
multiline: true,
|
||||
i18nLabel: 'Livechat_AdditionalWidgetScripts',
|
||||
i18nDescription: 'Livechat_AdditionalWidgetScripts_Description',
|
||||
enableQuery: [omnichannelEnabledQuery],
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_WidgetLayoutClasses', '', {
|
||||
type: 'string',
|
||||
group: 'Omnichannel',
|
||||
section: 'Livechat',
|
||||
enterprise: true,
|
||||
invalidValue: '',
|
||||
multiline: true,
|
||||
i18nLabel: 'Livechat_WidgetLayoutClasses',
|
||||
i18nDescription: 'Livechat_WidgetLayoutClasses_Description',
|
||||
enableQuery: [omnichannelEnabledQuery],
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_widget_position', 'right', {
|
||||
type: 'select',
|
||||
group: 'Omnichannel',
|
||||
section: 'Livechat',
|
||||
i18nLabel: 'Livechat_widget_position_on_the_screen',
|
||||
public: true,
|
||||
values: [
|
||||
{ key: 'left', i18nLabel: 'Left' },
|
||||
{ key: 'right', i18nLabel: 'Right' },
|
||||
],
|
||||
enterprise: true,
|
||||
invalidValue: 'right',
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_background', '', {
|
||||
type: 'string',
|
||||
group: 'Omnichannel',
|
||||
section: 'Livechat',
|
||||
i18nDescription: 'Livechat_background_description',
|
||||
placeholder: '#FFFFFF',
|
||||
public: true,
|
||||
enterprise: true,
|
||||
invalidValue: '',
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_hide_watermark', false, {
|
||||
type: 'boolean',
|
||||
group: 'Omnichannel',
|
||||
section: 'Livechat',
|
||||
invalidValue: false,
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
i18nDescription: 'Livechat_hide_watermark_description',
|
||||
enterprise: true,
|
||||
sorter: 999,
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Omnichannel_contact_manager_routing', true, {
|
||||
type: 'boolean',
|
||||
group: 'Omnichannel',
|
||||
section: 'Routing',
|
||||
enterprise: true,
|
||||
invalidValue: false,
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_auto_close_on_hold_chats_timeout', 3600, {
|
||||
type: 'int',
|
||||
group: 'Omnichannel',
|
||||
section: 'Sessions',
|
||||
enterprise: true,
|
||||
invalidValue: 0,
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_auto_close_on_hold_chats_custom_message', '', {
|
||||
type: 'string',
|
||||
group: 'Omnichannel',
|
||||
section: 'Sessions',
|
||||
enableQuery: [{ _id: 'Livechat_auto_close_on_hold_chats_timeout', value: { $gte: 1 } }, omnichannelEnabledQuery],
|
||||
enterprise: true,
|
||||
invalidValue: '',
|
||||
modules: ['livechat-enterprise'],
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_allow_manual_on_hold', false, {
|
||||
type: 'boolean',
|
||||
group: 'Omnichannel',
|
||||
section: 'Sessions',
|
||||
enterprise: true,
|
||||
invalidValue: false,
|
||||
public: true,
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_allow_manual_on_hold_upon_agent_engagement_only', true, {
|
||||
type: 'boolean',
|
||||
group: 'Omnichannel',
|
||||
section: 'Sessions',
|
||||
enterprise: true,
|
||||
invalidValue: false,
|
||||
public: true,
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: { _id: 'Livechat_allow_manual_on_hold', value: true },
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_auto_transfer_chat_timeout', 0, {
|
||||
type: 'int',
|
||||
group: 'Omnichannel',
|
||||
section: 'Sessions',
|
||||
i18nDescription: 'Livechat_auto_transfer_chat_timeout_description',
|
||||
enterprise: true,
|
||||
invalidValue: 0,
|
||||
modules: ['livechat-enterprise'],
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Accounts_Default_User_Preferences_omnichannelTranscriptPDF', false, {
|
||||
type: 'boolean',
|
||||
public: true,
|
||||
i18nLabel: 'Omnichannel_transcript_pdf',
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_hide_system_messages', ['uj', 'ul', 'livechat-close'], {
|
||||
type: 'multiSelect',
|
||||
group: 'Omnichannel',
|
||||
section: 'Livechat',
|
||||
enterprise: true,
|
||||
modules: ['livechat-enterprise'],
|
||||
invalidValue: ['uj', 'ul', 'livechat-close'],
|
||||
public: true,
|
||||
values: [
|
||||
{ key: 'uj', i18nLabel: 'Message_HideType_uj' },
|
||||
{ key: 'ul', i18nLabel: 'Message_HideType_ul' },
|
||||
{ key: 'livechat-close', i18nLabel: 'Message_HideType_livechat_closed' },
|
||||
{ key: 'livechat-started', i18nLabel: 'Message_HideType_livechat_started' },
|
||||
{ key: 'livechat_transfer_history', i18nLabel: 'Message_HideType_livechat_transfer_history' },
|
||||
],
|
||||
});
|
||||
|
||||
await Settings.addOptionValueById('Livechat_Routing_Method', {
|
||||
key: 'Load_Balancing',
|
||||
i18nLabel: 'Load_Balancing',
|
||||
});
|
||||
await Settings.addOptionValueById('Livechat_Routing_Method', {
|
||||
key: 'Load_Rotation',
|
||||
i18nLabel: 'Load_Rotation',
|
||||
});
|
||||
|
||||
await settingsRegistry.add('Livechat_hide_expand_chat', false, {
|
||||
type: 'boolean',
|
||||
group: 'Omnichannel',
|
||||
section: 'Livechat',
|
||||
i18nDescription: 'Livechat_hide_expand_chat_description',
|
||||
invalidValue: false,
|
||||
modules: ['livechat-enterprise'],
|
||||
enterprise: false,
|
||||
public: false,
|
||||
enableQuery: omnichannelEnabledQuery,
|
||||
});
|
||||
};
|
||||
44
apps/meteor/ee/server/lib/omnichannel/startup.ts
Normal file
44
apps/meteor/ee/server/lib/omnichannel/startup.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { updatePredictedVisitorAbandonment, updateQueueInactivityTimeout } from './Helper';
|
||||
import { VisitorInactivityMonitor } from './VisitorInactivityMonitor';
|
||||
import { MultipleBusinessHoursBehavior } from './business-hour/Multiple';
|
||||
import { logger } from './logger';
|
||||
import { businessHourManager } from '../../../../server/lib/omnichannel/business-hour';
|
||||
import { SingleBusinessHourBehavior } from '../../../../server/lib/omnichannel/business-hour/Single';
|
||||
import { settings } from '../../../../server/settings';
|
||||
|
||||
const visitorActivityMonitor = new VisitorInactivityMonitor();
|
||||
const businessHours = {
|
||||
Multiple: new MultipleBusinessHoursBehavior(),
|
||||
Single: new SingleBusinessHourBehavior(),
|
||||
};
|
||||
|
||||
settings.change('Livechat_max_queue_wait_time', async () => {
|
||||
await updateQueueInactivityTimeout();
|
||||
});
|
||||
|
||||
Meteor.startup(async () => {
|
||||
settings.watch('Livechat_abandoned_rooms_action', async (value) => {
|
||||
await updatePredictedVisitorAbandonment();
|
||||
if (!value || value === 'none') {
|
||||
return visitorActivityMonitor.stop();
|
||||
}
|
||||
await visitorActivityMonitor.start();
|
||||
});
|
||||
settings.change('Livechat_visitor_inactivity_timeout', async () => {
|
||||
await updatePredictedVisitorAbandonment();
|
||||
});
|
||||
settings.change<string>('Livechat_business_hour_type', async (value) => {
|
||||
logger.debug({ msg: 'Changing business hour type', value });
|
||||
if (!Object.keys(businessHours).includes(value)) {
|
||||
logger.error({ msg: 'Invalid business hour type', value });
|
||||
return;
|
||||
}
|
||||
businessHourManager.registerBusinessHourBehavior(businessHours[value as keyof typeof businessHours]);
|
||||
if (settings.get('Livechat_enable_business_hours')) {
|
||||
await businessHourManager.restartManager();
|
||||
logger.debug('Business hour manager started');
|
||||
}
|
||||
});
|
||||
});
|
||||
17
apps/meteor/ee/server/lib/omnichannel/unit.ts
Normal file
17
apps/meteor/ee/server/lib/omnichannel/unit.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { LivechatUnit } from '@rocket.chat/models';
|
||||
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import type { CheckUnitsFromUser } from '../../../../server/api/v1/omnichannel/lib/livechat';
|
||||
import { checkUnitsFromUser } from '../../../../server/api/v1/omnichannel/lib/livechat';
|
||||
|
||||
checkUnitsFromUser.patch(async (_next, { businessUnit, userId }: CheckUnitsFromUser) => {
|
||||
if (!businessUnit) {
|
||||
return;
|
||||
}
|
||||
const unitsFromUser = await getUnitsFromUser(userId);
|
||||
const unit = await LivechatUnit.findOneById(businessUnit, { projection: { _id: 1 } }, { unitsFromUser });
|
||||
if (!unit) {
|
||||
throw new Meteor.Error('error-unit-not-found', `Error! No Active Business Unit found with id: ${businessUnit}`);
|
||||
}
|
||||
});
|
||||
35
apps/meteor/ee/server/lib/roles/insertRole.ts
Normal file
35
apps/meteor/ee/server/lib/roles/insertRole.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { api, MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IRole } from '@rocket.chat/core-typings';
|
||||
import { Roles } from '@rocket.chat/models';
|
||||
|
||||
import { isValidRoleScope } from '../../../../lib/roles/isValidRoleScope';
|
||||
import { notifyOnRoleChanged } from '../../../../server/lib/notifyListener';
|
||||
|
||||
type InsertRoleOptions = {
|
||||
broadcastUpdate?: boolean;
|
||||
};
|
||||
|
||||
export const insertRoleAsync = async (roleData: Omit<IRole, '_id' | '_updatedAt'>, options: InsertRoleOptions = {}): Promise<IRole> => {
|
||||
const { name, scope, description, mandatory2fa } = roleData;
|
||||
|
||||
if (await Roles.findOneByName(name)) {
|
||||
throw new MeteorError('error-duplicate-role-names-not-allowed', 'Role name already exists');
|
||||
}
|
||||
|
||||
if (!isValidRoleScope(scope)) {
|
||||
throw new MeteorError('error-invalid-scope', 'Invalid scope');
|
||||
}
|
||||
|
||||
const role = await Roles.createWithRandomId(name, scope, description, false, mandatory2fa);
|
||||
|
||||
void notifyOnRoleChanged(role);
|
||||
|
||||
if (options.broadcastUpdate) {
|
||||
void api.broadcast('user.roleUpdate', {
|
||||
type: 'changed',
|
||||
_id: role._id,
|
||||
});
|
||||
}
|
||||
|
||||
return role;
|
||||
};
|
||||
62
apps/meteor/ee/server/lib/roles/updateRole.ts
Normal file
62
apps/meteor/ee/server/lib/roles/updateRole.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { api, MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IRole } from '@rocket.chat/core-typings';
|
||||
import { Roles } from '@rocket.chat/models';
|
||||
|
||||
import { isValidRoleScope } from '../../../../lib/roles/isValidRoleScope';
|
||||
import { notifyOnRoleChangedById } from '../../../../server/lib/notifyListener';
|
||||
|
||||
type UpdateRoleOptions = {
|
||||
broadcastUpdate?: boolean;
|
||||
};
|
||||
|
||||
export const updateRole = async (
|
||||
roleId: IRole['_id'],
|
||||
roleData: Omit<IRole, '_id' | '_updatedAt'>,
|
||||
options: UpdateRoleOptions = {},
|
||||
): Promise<IRole> => {
|
||||
const role = await Roles.findOneById(roleId);
|
||||
|
||||
if (!role) {
|
||||
throw new MeteorError('error-invalid-roleId', 'This role does not exist');
|
||||
}
|
||||
|
||||
if (role.protected && ((roleData.name && roleData.name !== role.name) || (roleData.scope && roleData.scope !== role.scope))) {
|
||||
throw new MeteorError('error-role-protected', 'Role is protected');
|
||||
}
|
||||
|
||||
if (roleData.name) {
|
||||
const otherRole = await Roles.findOneByName(roleData.name, { projection: { _id: 1 } });
|
||||
if (otherRole && otherRole._id !== role._id) {
|
||||
throw new MeteorError('error-duplicate-role-names-not-allowed', 'Role name already exists');
|
||||
}
|
||||
} else {
|
||||
roleData.name = role.name;
|
||||
}
|
||||
|
||||
if (roleData.scope) {
|
||||
if (!isValidRoleScope(roleData.scope)) {
|
||||
throw new MeteorError('error-invalid-scope', 'Invalid scope');
|
||||
}
|
||||
} else {
|
||||
roleData.scope = role.scope;
|
||||
}
|
||||
|
||||
await Roles.updateById(roleId, roleData.name, roleData.scope, roleData.description, roleData.mandatory2fa);
|
||||
|
||||
void notifyOnRoleChangedById(roleId);
|
||||
|
||||
if (options.broadcastUpdate) {
|
||||
void api.broadcast('user.roleUpdate', {
|
||||
type: 'changed',
|
||||
_id: roleId,
|
||||
scope: roleData.scope,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedRole = await Roles.findOneById(roleId);
|
||||
if (!updatedRole) {
|
||||
throw new MeteorError('error-role-not-found', 'Role not found');
|
||||
}
|
||||
|
||||
return updatedRole;
|
||||
};
|
||||
90
apps/meteor/ee/server/lib/syncUserRoles.ts
Normal file
90
apps/meteor/ee/server/lib/syncUserRoles.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { IUser, IRole, AtLeast } from '@rocket.chat/core-typings';
|
||||
import { License } from '@rocket.chat/license';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import { addUserRolesAsync } from '../../../server/lib/roles/addUserRoles';
|
||||
import { removeUserFromRolesAsync } from '../../../server/lib/roles/removeUserFromRoles';
|
||||
import { settings } from '../../../server/settings';
|
||||
|
||||
type setUserRolesOptions = {
|
||||
// If specified, the function will not add nor remove any role that is not on this list.
|
||||
allowedRoles?: Array<IRole['_id']>;
|
||||
// If set to true, roles will only be added, not removed
|
||||
skipRemovingRoles?: boolean;
|
||||
// the scope value (eg: room id) to assign the roles to
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
function filterRoleList(
|
||||
roleList: Array<IRole['_id']>,
|
||||
rolesToFilterOut: Array<IRole['_id']>,
|
||||
rolesToFilterIn?: Array<IRole['_id']>,
|
||||
): Array<IRole['_id']> {
|
||||
const filteredRoles = roleList.filter((roleId) => !rolesToFilterOut.includes(roleId));
|
||||
|
||||
if (!rolesToFilterIn) {
|
||||
return filteredRoles;
|
||||
}
|
||||
|
||||
return filteredRoles.filter((roleId) => rolesToFilterIn.includes(roleId));
|
||||
}
|
||||
|
||||
function broadcastRoleChange(
|
||||
type: 'changed' | 'added' | 'removed',
|
||||
roleList: Array<IRole['_id']>,
|
||||
user: AtLeast<IUser, '_id' | 'username'>,
|
||||
): void {
|
||||
if (!settings.get('UI_DisplayRoles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _id, username } = user;
|
||||
|
||||
for (const roleId of roleList) {
|
||||
void api.broadcast('user.roleUpdate', {
|
||||
type,
|
||||
_id: roleId,
|
||||
u: {
|
||||
_id,
|
||||
username,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncUserRoles(
|
||||
uid: IUser['_id'],
|
||||
newRoleList: Array<IRole['_id']>,
|
||||
{ allowedRoles, skipRemovingRoles, scope }: setUserRolesOptions,
|
||||
): Promise<void> {
|
||||
const user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'roles'>>(uid, { projection: { username: 1, roles: 1 } });
|
||||
if (!user) {
|
||||
throw new Error('error-user-not-found');
|
||||
}
|
||||
|
||||
const existingRoles = user.roles;
|
||||
const rolesToAdd = filterRoleList(newRoleList, existingRoles, allowedRoles);
|
||||
const rolesToRemove = filterRoleList(existingRoles, newRoleList, allowedRoles);
|
||||
|
||||
if (!rolesToAdd.length && !rolesToRemove.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasGuest = existingRoles.length === 1 && existingRoles[0] === 'guest';
|
||||
if (wasGuest && (await License.shouldPreventAction('activeUsers'))) {
|
||||
throw new Error('error-license-user-limit-reached');
|
||||
}
|
||||
|
||||
if (rolesToAdd.length && (await addUserRolesAsync(uid, rolesToAdd, scope))) {
|
||||
broadcastRoleChange('added', rolesToAdd, user);
|
||||
}
|
||||
|
||||
if (skipRemovingRoles || !rolesToRemove.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await removeUserFromRolesAsync(uid, rolesToRemove, scope)) {
|
||||
broadcastRoleChange('removed', rolesToRemove, user);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user