Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b013b9b
permissions & validations
KevLehman Aug 13, 2025
718db89
Update apps/meteor/ee/app/livechat-enterprise/server/outboundcomms/re…
KevLehman Aug 14, 2025
af85260
fixes an improvements
KevLehman Aug 14, 2025
3b02bfb
final fix
KevLehman Aug 18, 2025
80becf8
fixes
KevLehman Aug 18, 2025
3595401
fixes
KevLehman Aug 18, 2025
9de73ad
restore
KevLehman Aug 18, 2025
b45ab9f
again
KevLehman Aug 18, 2025
d58628e
remove comment
KevLehman Aug 18, 2025
c9b8b2c
Update apps/meteor/ee/app/livechat-enterprise/server/api/outbound.ts
KevLehman Aug 20, 2025
e3752d9
Update apps/meteor/ee/app/livechat-enterprise/server/api/outbound.ts
KevLehman Aug 20, 2025
6949aa5
Update outbound.ts
KevLehman Aug 20, 2025
9bc28af
Update outbound.ts
KevLehman Aug 20, 2025
6dfda5c
Merge branch 'develop' into chore/permissions-and-validations
KevLehman Aug 21, 2025
4bf2fc2
move to package
KevLehman Aug 21, 2025
6d80cc2
better error
KevLehman Aug 21, 2025
6fa9ec8
Merge branch 'develop' into chore/permissions-and-validations
KevLehman Aug 25, 2025
60b9749
import
KevLehman Aug 25, 2025
dc206c4
Update canSendMessage.ts
KevLehman Aug 25, 2025
c106c97
fix
KevLehman Aug 25, 2025
88c2e18
fix
KevLehman Aug 26, 2025
eed8ddb
Apply suggestion from @KevLehman
KevLehman Aug 26, 2025
4804553
Merge branch 'develop' into chore/permissions-and-validations
kodiakhq[bot] Sep 1, 2025
091da3b
Merge branch 'develop' into chore/permissions-and-validations
KevLehman Sep 1, 2025
ff5de95
Merge branch 'develop' into chore/permissions-and-validations
KevLehman Sep 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions apps/meteor/ee/app/livechat-enterprise/server/api/outbound.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models';
import {
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
validateUnauthorizedErrorResponse,
} from '@rocket.chat/rest-typings';
import type { FilterOperators } from 'mongodb';

import { API } from '../../../../../app/api/server';
import {
GETOutboundProvidersResponseSchema,
GETOutboundProviderParamsSchema,
GETOutboundProviderBadRequestErrorSchema,
GETOutboundProviderMetadataSchema,
POSTOutboundMessageParams,
POSTOutboundMessageErrorSchema,
POSTOutboundMessageSuccessSchema,
} from '../outboundcomms/rest';
import { outboundMessageProvider } from './lib/outbound';
import type { ExtractRoutesFromAPI } from '../../../../../app/api/server/ApiClass';
import { hasPermissionAsync } from '../../../../../app/authorization/server/functions/hasPermission';
import { restrictDepartmentsQuery } from '../lib/restrictQuery';

const outboundCommsEndpoints = API.v1
.get(
Expand All @@ -18,8 +28,11 @@ const outboundCommsEndpoints = API.v1
response: {
200: GETOutboundProvidersResponseSchema,
400: GETOutboundProviderBadRequestErrorSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
query: GETOutboundProviderParamsSchema,
permissionsRequired: ['outbound.send-messages'],
authRequired: true,
license: ['outbound-messaging'],
},
Expand All @@ -38,7 +51,10 @@ const outboundCommsEndpoints = API.v1
response: {
200: GETOutboundProviderMetadataSchema,
400: GETOutboundProviderBadRequestErrorSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
permissionsRequired: ['outbound.send-messages'],
authRequired: true,
license: ['outbound-messaging'],
},
Expand All @@ -54,13 +70,65 @@ const outboundCommsEndpoints = API.v1
.post(
'omnichannel/outbound/providers/:id/message',
{
response: { 200: POSTOutboundMessageSuccessSchema, 400: POSTOutboundMessageErrorSchema },
response: {
200: POSTOutboundMessageSuccessSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['outbound.send-messages'],
body: POSTOutboundMessageParams,
license: ['outbound-messaging'],
},
async function action() {
const { id } = this.urlParams;
const { departmentId, agentId } = this.bodyParams;

// Case 1: Check department and check if agent is in department
if (departmentId) {
let query: FilterOperators<ILivechatDepartment> = { _id: departmentId };
if (!(await hasPermissionAsync(this.userId, 'outbound.can-assign-queues'))) {
query = await restrictDepartmentsQuery({ originalQuery: query, userId: this.userId });
}

const department = await LivechatDepartment.findOne<Pick<ILivechatDepartment, '_id' | 'enabled'>>(query, { _id: 1, enabled: 1 });
if (!department?.enabled) {
return API.v1.failure('error-invalid-department');
}

// Case 2: Agent & department: if agent is present, agent must be in department
if (agentId) {
if (agentId !== this.userId && !(await hasPermissionAsync(this.userId, 'outbound.can-assign-any-agent'))) {
if (await hasPermissionAsync(this.userId, 'outbound.can-assign-self-only')) {
// Override agentId when user has permission to assign self only
this.bodyParams.agentId = this.userId;
} else {
return API.v1.forbidden('afdsfads');
}
}

// On here, we take a shortcut: if the user is here, we assume it's an agent (and we assume the collection is kept up to date :) )
const agent = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(this.bodyParams.agentId!, departmentId);
if (!agent) {
return API.v1.failure('error-agent-not-in-department');
}
}
// Case 3: Agent & no department: if agent is present and there's no department, agent must be an agent
} else if (agentId) {
if (agentId !== this.userId && !(await hasPermissionAsync(this.userId, 'outbound.can-assign-any-agent'))) {
if (await hasPermissionAsync(this.userId, 'outbound.can-assign-self-only')) {
this.bodyParams.agentId = this.userId;
} else {
return API.v1.forbidden('asdfadfdas');
}
}

const agent = await Users.findOneAgentById(this.bodyParams.agentId!, { projection: { _id: 1 } });
if (!agent) {
return API.v1.failure('error-agent-not-in-department');
}
}

await outboundMessageProvider.sendMessage(id, this.bodyParams);
return API.v1.success();
Expand Down
25 changes: 23 additions & 2 deletions apps/meteor/ee/app/livechat-enterprise/server/lib/restrictQuery.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { ILivechatDepartment, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import type { FilterOperators } from 'mongodb';

import { cbLogger } from './logger';
import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles';
import { getUnitsFromUser, memoizedGetDepartmentsFromUserRoles, memoizedGetUnitFromUserRoles } from '../methods/getUnitsFromUserRoles';

export const restrictQuery = async ({
originalQuery = {},
Expand Down Expand Up @@ -44,3 +44,24 @@ export const restrictQuery = async ({
cbLogger.debug({ msg: 'Applying room query restrictions', userUnits });
return query;
};

export const restrictDepartmentsQuery = async ({
originalQuery = {},
userId,
}: {
originalQuery?: FilterOperators<ILivechatDepartment>;
userId: string;
}) => {
const query: FilterOperators<ILivechatDepartment> = { ...originalQuery };

const userUnits = await memoizedGetUnitFromUserRoles(userId);
const userDepartments = await memoizedGetDepartmentsFromUserRoles(userId);
const expressions = query.$and || [];
const condition = {
$or: [{ ancestors: { $in: userUnits } }, { _id: { $in: userDepartments } }],
};
query.$and = [condition, ...expressions];

cbLogger.debug({ msg: 'Applying department query restrictions', userUnits });
return query;
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ async function getDepartmentsFromUserRoles(user: string): Promise<string[]> {
return (await LivechatDepartmentAgents.findByAgentId(user).toArray()).map((department) => department.departmentId);
}

const memoizedGetUnitFromUserRoles = mem(getUnitsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 });
const memoizedGetDepartmentsFromUserRoles = mem(getDepartmentsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 });
export const memoizedGetUnitFromUserRoles = mem(getUnitsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 });
export const memoizedGetDepartmentsFromUserRoles = mem(getDepartmentsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 });

async function hasUnits(): Promise<boolean> {
// @ts-expect-error - this prop is injected dynamically on ee license
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,33 +178,22 @@ const POSTOutboundMessageSchema = {
},
additionalProperties: false,
},
agentId: { type: 'string' },
departmentId: { type: 'string' },
},
additionalProperties: false,
};

export const POSTOutboundMessageParams = ajv.compile<POSTOutboundMessageParamsType>(POSTOutboundMessageSchema);

const POSTOutboundMessageError = {
const POSTOutboundMessageSuccess = {
type: 'object',
properties: {
success: {
type: 'boolean',
},
message: {
type: 'string',
},
success: { type: 'boolean', enum: [true] },
},
additionalProperties: false,
};

export const POSTOutboundMessageErrorSchema = ajv.compile<GenericErrorResponse>(POSTOutboundMessageError);

const POSTOutboundMessageSuccess = {
type: 'object',
properties: {},
additionalProperties: false,
};

export const POSTOutboundMessageSuccessSchema = ajv.compile<void>(POSTOutboundMessageSuccess);

const OutboundProviderMetadataSchema = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export const omnichannelEEPermissions = [
{ _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> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export interface IOutboundMessage {
to: string;
type: 'template';
templateProviderPhoneNumber: string;
agentId?: string;
departmentId?: string;
template: {
name: string;
language: {
Expand Down
2 changes: 2 additions & 0 deletions packages/core-typings/src/omnichannel/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface IOutboundMessage {
to: string;
type: 'template';
templateProviderPhoneNumber: string;
departmentId?: string;
agentId?: string;
template: {
name: string;
language: {
Expand Down
8 changes: 8 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -6519,6 +6519,14 @@
"others": "others",
"outbound-voip-calls": "Outbound Voip Calls",
"outbound-voip-calls_description": "Permission to outbound voip calls",
"outbound.send-messages": "Send outbound messages",
"outbound.send-messages_description": "Permission to send outbound messages",
"outbound.can-assign-queues": "Can assign departments to receive outbound messages responses",
"outbound.can-assign-queues_description": "Permission to assign departments to receive outbound messages responses",
"outbound.can-assign-any-agent": "Can assign any agent to receive outbound messages responses",
"outbound.can-assign-any-agent_description": "Permission to assign any agent to receive outbound messages responses",
"outbound.can-assign-self-only": "Can assign self only to receive outbound messages responses",
"outbound.can-assign-self-only_description": "Permission to assign self only to receive outbound messages responses",
"pdf_error_message": "Error generating PDF Transcript",
"pdf_success_message": "PDF Transcript successfully generated",
"pending": "pending",
Expand Down
Loading