fix: Omnichannel Tags available to be used in the wrong department (#29169)

Co-authored-by: Martin Schoeler <20868078+MartinSchoeler@users.noreply.github.com>
pull/28783/head^2
Murtaza Patrawala 3 years ago committed by GitHub
parent dbc79dd3ee
commit eecd9fc99a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      .changeset/great-brooms-invent.md
  2. 12
      apps/meteor/client/components/Omnichannel/Tags.tsx
  3. 19
      apps/meteor/client/components/Omnichannel/hooks/useLivechatTags.ts
  4. 2
      apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx
  5. 64
      apps/meteor/ee/app/api-enterprise/server/lib/canned-responses.js
  6. 38
      apps/meteor/ee/app/livechat-enterprise/server/api/lib/departments.ts
  7. 49
      apps/meteor/ee/app/livechat-enterprise/server/api/lib/tags.ts
  8. 4
      apps/meteor/ee/app/livechat-enterprise/server/api/tags.ts
  9. 4
      apps/meteor/ee/client/hooks/useTagsList.ts
  10. 4
      apps/meteor/ee/client/omnichannel/tags/AutoCompleteTagsMultiple.js
  11. 4
      apps/meteor/ee/client/omnichannel/tags/CurrentChatTags.tsx
  12. 1
      apps/meteor/ee/client/omnichannel/tags/TagsTable.tsx
  13. 2
      apps/meteor/ee/server/models/raw/LivechatUnit.ts
  14. 43
      apps/meteor/server/models/raw/LivechatDepartment.ts
  15. 26
      apps/meteor/tests/data/livechat/tags.ts
  16. 92
      apps/meteor/tests/end-to-end/api/livechat/13-tags.ts
  17. 1
      packages/model-typings/src/models/ILivechatDepartmentModel.ts
  18. 11
      packages/rest-typings/src/v1/omnichannel.ts

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/rest-typings": patch
---
fix: Omnichannel Tags available to be used in the wrong department

@ -1,23 +1,25 @@
import { Field, TextInput, Chip, Button } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import type { ChangeEvent, ReactElement } from 'react';
import React, { useState } from 'react';
import { useFormsSubscription } from '../../views/omnichannel/additionalForms';
import { FormSkeleton } from './Skeleton';
import { useLivechatTags } from './hooks/useLivechatTags';
const Tags = ({
tags = [],
handler,
error,
tagRequired,
department,
}: {
tags?: string[];
handler: (value: string[]) => void;
error?: string;
tagRequired?: boolean;
department?: string;
}): ReactElement => {
const t = useTranslation();
const forms = useFormsSubscription() as any;
@ -27,9 +29,8 @@ const Tags = ({
// Conditional hook was required since the whole formSubscription uses hooks in an incorrect manner
const EETagsComponent = useCurrentChatTags?.();
const getTags = useEndpoint('GET', '/v1/livechat/tags');
const { data: tagsResult, isInitialLoading } = useQuery(['/v1/livechat/tags'], () => getTags({ text: '' }), {
enabled: Boolean(EETagsComponent),
const { data: tagsResult, isInitialLoading } = useLivechatTags({
department,
});
const dispatchToastMessage = useToastMessageDispatch();
@ -81,6 +82,7 @@ const Tags = ({
handler(tags.map((tag) => tag.label));
handlePaginatedTagValue(tags);
}}
department={department}
/>
</Field.Row>
) : (

@ -0,0 +1,19 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
type Props = {
department?: string;
text?: string;
};
export const useLivechatTags = (options: Props) => {
const getTags = useEndpoint('GET', '/v1/livechat/tags');
const { department, text } = options;
return useQuery(['/v1/livechat/tags', text, department], () =>
getTags({
text: text || '',
...(department && { department }),
}),
);
};

@ -150,7 +150,7 @@ const CloseChatModal = ({
<Field.Error>{errors.comment?.message}</Field.Error>
</Field>
<Field>
<Tags tagRequired={tagRequired} tags={tags} handler={handleTags} />
<Tags tagRequired={tagRequired} tags={tags} handler={handleTags} {...(department && { department: department._id })} />
<Field.Error>{errors.tags?.message}</Field.Error>
</Field>
{canSendTranscript && (

@ -1,7 +1,8 @@
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { LivechatDepartmentAgents, CannedResponse, LivechatUnit } from '@rocket.chat/models';
import { CannedResponse } from '@rocket.chat/models';
import { hasPermissionAsync } from '../../../../../app/authorization/server/functions/hasPermission';
import { getDepartmentsWhichUserCanAccess } from '../../../livechat-enterprise/server/api/lib/departments';
export async function findAllCannedResponses({ userId }) {
// If the user is an admin or livechat manager, get his own responses and all responses from all departments
@ -30,23 +31,8 @@ export async function findAllCannedResponses({ userId }) {
}).toArray();
}
// Last scenario: user is an agente, so get his own responses and those from the departments he is in
const departments = await LivechatDepartmentAgents.find(
{
agentId: userId,
},
{
projection: {
departmentId: 1,
},
},
).toArray();
const monitoredDepartments = await LivechatUnit.findMonitoredDepartmentsByMonitorId(userId);
const combinedDepartments = [
...departments.map((department) => department.departmentId),
...monitoredDepartments.map((department) => department._id),
];
// Last scenario: user is an agent, so get his own responses and those from the departments he is in
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId);
return CannedResponse.find({
$or: [
@ -57,7 +43,7 @@ export async function findAllCannedResponses({ userId }) {
{
scope: 'department',
departmentId: {
$in: combinedDepartments,
$in: accessibleDepartments,
},
},
{
@ -71,26 +57,11 @@ export async function findAllCannedResponsesFilter({ userId, shortcut, text, dep
let extraFilter = [];
// if user cannot see all, filter to private + public + departments user is in
if (!(await hasPermissionAsync(userId, 'view-all-canned-responses'))) {
const departments = await LivechatDepartmentAgents.find(
{
agentId: userId,
},
{
fields: {
departmentId: 1,
},
},
).toArray();
const monitoredDepartments = await LivechatUnit.findMonitoredDepartmentsByMonitorId(userId);
const combinedDepartments = [
...departments.map((department) => department.departmentId),
...monitoredDepartments.map((department) => department._id),
];
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId);
const isDepartmentInScope = (departmentId) => !!combinedDepartments.includes(departmentId);
const isDepartmentInScope = (departmentId) => !!accessibleDepartments.includes(departmentId);
const departmentIds = departmentId && isDepartmentInScope(departmentId) ? [departmentId] : combinedDepartments;
const departmentIds = departmentId && isDepartmentInScope(departmentId) ? [departmentId] : accessibleDepartments;
extraFilter = [
{
@ -163,22 +134,7 @@ export async function findOneCannedResponse({ userId, _id }) {
return CannedResponse.findOneById(_id);
}
const departments = await LivechatDepartmentAgents.find(
{
agentId: userId,
},
{
fields: {
departmentId: 1,
},
},
).toArray();
const monitoredDepartments = await LivechatUnit.findMonitoredDepartmentsByMonitorId(userId);
const combinedDepartments = [
...departments.map((department) => department.departmentId),
...monitoredDepartments.map((department) => department._id),
];
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId);
const filter = {
_id,
@ -190,7 +146,7 @@ export async function findOneCannedResponse({ userId, _id }) {
{
scope: 'department',
departmentId: {
$in: combinedDepartments,
$in: accessibleDepartments,
},
},
{

@ -0,0 +1,38 @@
import { LivechatDepartment, LivechatDepartmentAgents, LivechatUnit } from '@rocket.chat/models';
import { helperLogger } from '../../lib/logger';
export const getDepartmentsWhichUserCanAccess = async (userId: string): Promise<string[]> => {
const departments = await LivechatDepartmentAgents.find(
{
agentId: userId,
},
{
projection: {
departmentId: 1,
},
},
).toArray();
const monitoredDepartments = await LivechatUnit.findMonitoredDepartmentsByMonitorId(userId);
const combinedDepartments = [
...departments.map((department) => department.departmentId),
...monitoredDepartments.map((department) => department._id),
];
return [...new Set(combinedDepartments)];
};
export const hasAccessToDepartment = async (userId: string, departmentId: string): Promise<boolean> => {
const department = await LivechatDepartmentAgents.findOneByAgentIdAndDepartmentId(userId, departmentId);
if (department) {
helperLogger.debug(`User ${userId} has access to department ${departmentId} because they are an agent`);
return true;
}
const monitorAccess = await LivechatDepartment.checkIfMonitorIsMonitoringDepartmentById(userId, departmentId);
helperLogger.debug(
`User ${userId} ${monitorAccess ? 'has' : 'does not have'} access to department ${departmentId} because they are a monitor`,
);
return monitorAccess;
};

@ -1,7 +1,10 @@
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { LivechatTag } from '@rocket.chat/models';
import type { ILivechatTag } from '@rocket.chat/core-typings';
import type { FindOptions } from 'mongodb';
import type { Filter, FindOptions } from 'mongodb';
import { hasPermissionAsync } from '../../../../../../app/authorization/server/functions/hasPermission';
import { hasAccessToDepartment } from './departments';
type FindTagsParams = {
userId: string;
@ -11,6 +14,8 @@ type FindTagsParams = {
count: number;
sort: FindOptions<ILivechatTag>['sort'];
};
department?: string;
viewAll?: boolean;
};
type FindTagsResult = {
@ -27,11 +32,47 @@ type FindTagsByIdParams = {
type FindTagsByIdResult = ILivechatTag | null;
export async function findTags({ text, pagination: { offset, count, sort } }: FindTagsParams): Promise<FindTagsResult> {
const query = {
...(text && { $or: [{ name: new RegExp(escapeRegExp(text), 'i') }, { description: new RegExp(escapeRegExp(text), 'i') }] }),
// If viewAll is true, then all tags will be returned, regardless of department
// If viewAll is false, then all public tags will be returned, and
// if department is specified, then all department tags will be returned
export async function findTags({
userId,
text,
department,
viewAll,
pagination: { offset, count, sort },
}: FindTagsParams): Promise<FindTagsResult> {
if (!(await hasPermissionAsync(userId, 'manage-livechat-tags'))) {
if (viewAll) {
viewAll = false;
}
if (department) {
if (!(await hasAccessToDepartment(userId, department))) {
department = undefined;
}
}
}
const query: {
$and?: Filter<ILivechatTag>[];
} = {
$and: [
...(text ? [{ $or: [{ name: new RegExp(escapeRegExp(text), 'i') }, { description: new RegExp(escapeRegExp(text), 'i') }] }] : []),
...(!viewAll
? [
{
$or: [{ departments: { $size: 0 } }, ...(department ? [{ departments: department }] : [])],
},
]
: []),
],
};
if (!query?.$and?.length) {
delete query.$and;
}
const { cursor, totalCount } = LivechatTag.findPaginated(query, {
sort: sort || { name: 1 },
skip: offset,

@ -9,12 +9,14 @@ API.v1.addRoute(
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort } = await this.parseJsonQuery();
const { text } = this.queryParams;
const { text, viewAll, department } = this.queryParams;
return API.v1.success(
await findTags({
userId: this.userId,
text,
department,
viewAll: viewAll === 'true',
pagination: {
offset,
count,

@ -8,6 +8,7 @@ import { RecordList } from '../../../client/lib/lists/RecordList';
type TagsListOptions = {
filter: string;
department?: string;
};
export const useTagsList = (
@ -33,6 +34,7 @@ export const useTagsList = (
text: options.filter,
offset: start,
count: end + start,
...(options.department && { department: options.department }),
});
return {
items: tags.map((tag: any) => {
@ -44,7 +46,7 @@ export const useTagsList = (
itemCount: total,
};
},
[getTags, options.filter],
[getTags, options.filter, options.department],
);
const { loadMoreItems, initialItemCount } = useScrollableRecordList(itemsList, fetchData, 25);

@ -8,7 +8,7 @@ import { AsyncStatePhase } from '../../../../client/hooks/useAsyncState';
import { useTagsList } from '../../hooks/useTagsList';
const AutoCompleteTagMultiple = (props) => {
const { value, onlyMyTags = false, onChange = () => {} } = props;
const { value, onlyMyTags = false, onChange = () => {}, department } = props;
const t = useTranslation();
const [tagsFilter, setTagsFilter] = useState('');
@ -16,7 +16,7 @@ const AutoCompleteTagMultiple = (props) => {
const debouncedTagsFilter = useDebouncedValue(tagsFilter, 500);
const { itemsList: tagsList, loadMoreItems: loadMoreTags } = useTagsList(
useMemo(() => ({ filter: debouncedTagsFilter, onlyMyTags }), [debouncedTagsFilter, onlyMyTags]),
useMemo(() => ({ filter: debouncedTagsFilter, onlyMyTags, department }), [debouncedTagsFilter, onlyMyTags, department]),
);
const { phase: tagsPhase, items: tagsItems, itemCount: tagsTotal } = useRecordList(tagsList);

@ -3,8 +3,8 @@ import React from 'react';
import AutoCompleteTagsMultiple from './AutoCompleteTagsMultiple';
const CurrentChatTags: FC<{ value: Array<string>; handler: () => void }> = ({ value, handler }) => (
<AutoCompleteTagsMultiple onChange={handler} value={value} />
const CurrentChatTags: FC<{ value: Array<string>; handler: () => void; department?: string }> = ({ value, handler, department }) => (
<AutoCompleteTagsMultiple onChange={handler} value={value} department={department} />
);
export default CurrentChatTags;

@ -39,6 +39,7 @@ const TagsTable = ({ reload }: { reload: MutableRefObject<() => void> }) => {
const query = useMemo(
() => ({
viewAll: 'true' as const,
fields: JSON.stringify({ name: 1 }),
text: filter,
sort: JSON.stringify({ [sortBy]: sortDirection === 'asc' ? 1 : -1 }),

@ -202,7 +202,7 @@ export class LivechatUnitRaw extends BaseRaw<IOmnichannelBusinessUnit> implement
async findMonitoredDepartmentsByMonitorId(monitorId: string): Promise<ILivechatDepartment[]> {
const monitoredUnits = await this.findByMonitorId(monitorId);
return LivechatDepartment.findByUnitIds(monitoredUnits, {}).toArray();
return LivechatDepartment.findActiveByUnitIds(monitoredUnits, {}).toArray();
}
countUnits(): Promise<number> {

@ -13,7 +13,7 @@ import type {
UpdateFilter,
} from 'mongodb';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { LivechatDepartmentAgents } from '@rocket.chat/models';
import { LivechatDepartmentAgents, LivechatUnitMonitors } from '@rocket.chat/models';
import { BaseRaw } from './BaseRaw';
@ -353,6 +353,47 @@ export class LivechatDepartmentRaw extends BaseRaw<ILivechatDepartment> implemen
return this.find(query, options);
}
checkIfMonitorIsMonitoringDepartmentById(monitorId: string, departmentId: string): Promise<boolean> {
const aggregation = [
{
$match: {
enabled: true,
_id: departmentId,
},
},
{
$lookup: {
from: LivechatUnitMonitors.getCollectionName(),
localField: 'parentId',
foreignField: 'unitId',
as: 'monitors',
pipeline: [
{
$match: {
monitorId,
},
},
],
},
},
{
$match: {
monitors: {
$exists: true,
$ne: [],
},
},
},
{
$project: {
_id: 1,
},
},
];
return this.col.aggregate(aggregation).hasNext();
}
}
const difference = <T>(arr: T[], arr2: T[]): T[] => {

@ -3,7 +3,7 @@ import type { ILivechatTag } from '@rocket.chat/core-typings';
import { credentials, methodCall, request } from '../api-data';
import type { DummyResponse } from './utils';
export const saveTags = (): Promise<ILivechatTag> => {
export const saveTags = (departments: string[] = []): Promise<ILivechatTag> => {
return new Promise((resolve, reject) => {
request
.post(methodCall(`livechat:saveTag`))
@ -11,7 +11,29 @@ export const saveTags = (): Promise<ILivechatTag> => {
.send({
message: JSON.stringify({
method: 'livechat:saveTag',
params: [undefined, { name: faker.person.firstName(), description: faker.lorem.sentence() }, []],
params: [undefined, { name: faker.person.firstName(), description: faker.lorem.sentence() }, departments],
id: '101',
msg: 'method',
}),
})
.end((err: Error, res: DummyResponse<string, 'wrapped'>) => {
if (err) {
return reject(err);
}
resolve(JSON.parse(res.body.message).result);
});
});
};
export const removeTag = (id: string): Promise<void> => {
return new Promise((resolve, reject) => {
request
.post(methodCall(`livechat:removeTag`))
.set(credentials)
.send({
message: JSON.stringify({
method: 'livechat:removeTag',
params: [id],
id: '101',
msg: 'method',
}),

@ -1,11 +1,13 @@
/* eslint-env mocha */
import { expect } from 'chai';
import type { ILivechatDepartment, ILivechatTag } from '@rocket.chat/core-typings';
import { getCredentials, api, request, credentials } from '../../../data/api-data';
import { saveTags } from '../../../data/livechat/tags';
import { removeTag, saveTags } from '../../../data/livechat/tags';
import { updatePermission, updateSetting } from '../../../data/permissions.helper';
import { IS_EE } from '../../../e2e/config/constants';
import { createDepartment } from '../../../data/livechat/rooms';
(IS_EE ? describe : describe.skip)('[EE] Livechat - Tags', function () {
this.retries(0);
@ -17,26 +19,104 @@ import { IS_EE } from '../../../e2e/config/constants';
});
describe('livechat/tags', () => {
let tagsData: {
caseA: { department: ILivechatDepartment; tag: ILivechatTag };
caseB: { department: ILivechatDepartment; tag: ILivechatTag };
casePublic: { tag: ILivechatTag };
};
it('should throw unauthorized error when the user does not have the necessary permission', async () => {
await updatePermission('manage-livechat-tags', []);
await updatePermission('view-l-room', []);
const response = await request.get(api('livechat/tags')).set(credentials).expect('Content-Type', 'application/json').expect(403);
expect(response.body).to.have.property('success', false);
await updatePermission('manage-livechat-tags', ['admin']);
await updatePermission('view-l-room', ['livechat-agent', 'livechat-manager', 'admin']);
});
it('should remove all existing tags', async () => {
const allTags = await request
.get(api('livechat/tags'))
.set(credentials)
.query({ viewAll: 'true' })
.expect('Content-Type', 'application/json')
.expect(200);
const { tags } = allTags.body;
for await (const tag of tags) {
await removeTag(tag._id);
}
const response = await request.get(api('livechat/tags')).set(credentials).expect('Content-Type', 'application/json').expect(200);
expect(response.body).to.have.property('success', true);
expect(response.body).to.have.property('tags').and.to.be.an('array').that.is.empty;
});
it('should add 3 tags', async () => {
const dA = await createDepartment();
const tagA = await saveTags([dA._id]);
const dB = await createDepartment();
const tagB = await saveTags([dB._id]);
const publicTag = await saveTags();
tagsData = {
caseA: { department: dA, tag: tagA },
caseB: { department: dB, tag: tagB },
casePublic: { tag: publicTag },
};
});
it('should return an array of tags', async () => {
await updatePermission('manage-livechat-tags', ['admin']);
await updatePermission('view-l-room', ['livechat-agent']);
const tag = await saveTags();
const { tag } = tagsData.caseA;
const response = await request
.get(api('livechat/tags'))
.set(credentials)
.query({ text: tag.name })
.query({ text: tag.name, viewAll: 'true' })
.expect('Content-Type', 'application/json')
.expect(200);
expect(response.body).to.have.property('success', true);
expect(response.body.tags).to.be.an('array').with.lengthOf.greaterThan(0);
expect(response.body.tags).to.be.an('array').with.lengthOf(1);
expect(response.body.tags[0]).to.have.property('_id', tag._id);
});
it('show return all tags when "viewAll" param is true', async () => {
const response = await request
.get(api('livechat/tags'))
.set(credentials)
.query({ viewAll: 'true' })
.expect('Content-Type', 'application/json')
.expect(200);
expect(response.body).to.have.property('success', true);
expect(response.body.tags).to.be.an('array').with.lengthOf(3);
const expectedTags = [tagsData.caseA.tag, tagsData.caseB.tag, tagsData.casePublic.tag].map((tag) => tag._id).sort();
const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort();
expect(actualTags).to.deep.equal(expectedTags);
});
it('should return department tags and public tags when "departmentId" param is provided', async () => {
const { department } = tagsData.caseA;
const response = await request
.get(api('livechat/tags'))
.set(credentials)
.query({ department: department._id })
.expect('Content-Type', 'application/json')
.expect(200);
expect(response.body).to.have.property('success', true);
expect(response.body.tags).to.be.an('array').with.lengthOf(2);
const expectedTags = [tagsData.caseA.tag, tagsData.casePublic.tag].map((tag) => tag._id).sort();
const actualTags = response.body.tags.map((tag: ILivechatTag) => tag._id).sort();
expect(actualTags).to.deep.equal(expectedTags);
});
it('should return public tags when "departmentId" param is not provided', async () => {
const response = await request.get(api('livechat/tags')).set(credentials).expect('Content-Type', 'application/json').expect(200);
expect(response.body).to.have.property('success', true);
expect(response.body.tags).to.be.an('array').with.lengthOf(1);
expect(response.body.tags[0]).to.have.property('_id', tagsData.casePublic.tag._id);
});
});
describe('livechat/tags/:tagId', () => {

@ -59,4 +59,5 @@ export interface ILivechatDepartmentModel extends IBaseModel<ILivechatDepartment
findOneByIdOrName(_idOrName: string, options?: FindOptions<ILivechatDepartment>): Promise<ILivechatDepartment | null>;
findByUnitIds(unitIds: string[], options?: FindOptions<ILivechatDepartment>): FindCursor<ILivechatDepartment>;
findActiveByUnitIds(unitIds: string[], options?: FindOptions<ILivechatDepartment>): FindCursor<ILivechatDepartment>;
checkIfMonitorIsMonitoringDepartmentById(monitorId: string, departmentId: string): Promise<boolean>;
}

@ -463,7 +463,7 @@ const LivechatMonitorsListSchema = {
export const isLivechatMonitorsListProps = ajv.compile<LivechatMonitorsListProps>(LivechatMonitorsListSchema);
type LivechatTagsListProps = PaginatedRequest<{ text: string }, 'name'>;
type LivechatTagsListProps = PaginatedRequest<{ text: string; viewAll?: 'true' | 'false'; department?: string }, 'name'>;
const LivechatTagsListSchema = {
type: 'object',
@ -471,6 +471,15 @@ const LivechatTagsListSchema = {
text: {
type: 'string',
},
department: {
type: 'string',
nullable: true,
},
viewAll: {
type: 'string',
nullable: true,
enum: ['true', 'false'],
},
count: {
type: 'number',
nullable: true,

Loading…
Cancel
Save