Skip to content

Commit

Permalink
fix: Omnichannel Tags available to be used in the wrong department (R…
Browse files Browse the repository at this point in the history
…ocketChat#29169)

Co-authored-by: Martin Schoeler <20868078+MartinSchoeler@users.noreply.github.com>
  • Loading branch information
murtaza98 and MartinSchoeler authored Jul 3, 2023
1 parent dbc79dd commit eecd9fc
Show file tree
Hide file tree
Showing 18 changed files with 301 additions and 81 deletions.
6 changes: 6 additions & 0 deletions .changeset/great-brooms-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/rest-typings": patch
---

fix: Omnichannel Tags available to be used in the wrong department
12 changes: 7 additions & 5 deletions apps/meteor/client/components/Omnichannel/Tags.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -81,6 +82,7 @@ const Tags = ({
handler(tags.map((tag) => tag.label));
handlePaginatedTagValue(tags);
}}
department={department}
/>
</Field.Row>
) : (
Expand Down
19 changes: 19 additions & 0 deletions apps/meteor/client/components/Omnichannel/hooks/useLivechatTags.ts
Original file line number Diff line number Diff line change
@@ -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 }),
}),
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
Expand Down
64 changes: 10 additions & 54 deletions apps/meteor/ee/app/api-enterprise/server/lib/canned-responses.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: [
Expand All @@ -57,7 +43,7 @@ export async function findAllCannedResponses({ userId }) {
{
scope: 'department',
departmentId: {
$in: combinedDepartments,
$in: accessibleDepartments,
},
},
{
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -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,
Expand All @@ -190,7 +146,7 @@ export async function findOneCannedResponse({ userId, _id }) {
{
scope: 'department',
departmentId: {
$in: combinedDepartments,
$in: accessibleDepartments,
},
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
};
49 changes: 45 additions & 4 deletions apps/meteor/ee/app/livechat-enterprise/server/api/lib/tags.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,6 +14,8 @@ type FindTagsParams = {
count: number;
sort: FindOptions<ILivechatTag>['sort'];
};
department?: string;
viewAll?: boolean;
};

type FindTagsResult = {
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/meteor/ee/app/livechat-enterprise/server/api/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/meteor/ee/client/hooks/useTagsList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { RecordList } from '../../../client/lib/lists/RecordList';

type TagsListOptions = {
filter: string;
department?: string;
};

export const useTagsList = (
Expand All @@ -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) => {
Expand All @@ -44,7 +46,7 @@ export const useTagsList = (
itemCount: total,
};
},
[getTags, options.filter],
[getTags, options.filter, options.department],
);

const { loadMoreItems, initialItemCount } = useScrollableRecordList(itemsList, fetchData, 25);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ 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('');

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);
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/ee/client/omnichannel/tags/CurrentChatTags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions apps/meteor/ee/client/omnichannel/tags/TagsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/ee/server/models/raw/LivechatUnit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
Loading

0 comments on commit eecd9fc

Please sign in to comment.