Skip to content

Commit

Permalink
feat: block contact channel (#33590)
Browse files Browse the repository at this point in the history
* feat: add new block contact channel endpoint

* chore: eslint fix

* fix: module name

* fix: remove module from license package

* feat: close room for blocked contacts

* feat: throw an error if contact is blocked

* refactor: move typings to ee

* fix: closes the room as the agent

* chore: register the api endpoints

* feat: add permissions

* test: ensure that blocking feature is working as intende

* fix: forbid creation of rooms if contact is blocked

* test: increase coverage

* fix: remove Meteor usage

* refactor: make update easier to read

* fix: check if license has the right module

* fix: remove empty line

* fix: remove _id from projection

* refactor: return a promise to avoid waiting twice

* updated code to new data format

* lints

---------

Co-authored-by: Matheus Barbosa Silva <[email protected]>
Co-authored-by: Pierre <[email protected]>
  • Loading branch information
3 people authored Oct 18, 2024
1 parent e8444c0 commit b47fb3c
Show file tree
Hide file tree
Showing 13 changed files with 369 additions and 31 deletions.
10 changes: 4 additions & 6 deletions apps/meteor/app/livechat/server/lib/Helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,10 @@ export const createLivechatRoom = async <
visitor: { _id, username, departmentId, status, activity },
});

const contactId = await (async () => {
return migrateVisitorIfMissingContact(
_id,
(extraRoomInfo.source || roomInfo.source || { type: OmnichannelSourceType.OTHER }) as IOmnichannelSource,
);
})();
const contactId = await migrateVisitorIfMissingContact(
_id,
(extraRoomInfo.source || roomInfo.source || { type: OmnichannelSourceType.OTHER }) as IOmnichannelSource,
);

// TODO: Solve `u` missing issue
const room: InsertionModel<IOmnichannelRoom> = {
Expand Down
9 changes: 9 additions & 0 deletions apps/meteor/app/livechat/server/lib/LivechatTyped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ReadReceipts,
Rooms,
LivechatCustomField,
LivechatContacts,
} from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Match, check } from 'meteor/check';
Expand Down Expand Up @@ -446,6 +447,10 @@ class LivechatClass {
}
}

if (await LivechatContacts.isChannelBlocked(visitor._id)) {
throw new Error('error-contact-channel-blocked');
}

// delegate room creation to QueueManager
Livechat.logger.debug(`Calling QueueManager to request a room for visitor ${visitor._id}`);

Expand Down Expand Up @@ -487,6 +492,10 @@ class LivechatClass {
Livechat.logger.debug(`Attempting to find or create a room for visitor ${guest._id}`);
const room = await LivechatRooms.findOneById(message.rid);

if (room?.v._id && (await LivechatContacts.isChannelBlocked(room?.v._id))) {
throw new Error('error-contact-channel-blocked');
}

if (room && !room.open) {
Livechat.logger.debug(`Last room for visitor ${guest._id} closed. Creating new one`);
}
Expand Down
87 changes: 87 additions & 0 deletions apps/meteor/ee/app/livechat-enterprise/server/api/contacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Ajv from 'ajv';

import { API } from '../../../../../app/api/server';
import { changeContactBlockStatus, closeBlockedRoom, ensureSingleContactLicense } from './lib/contacts';

const ajv = new Ajv({
coerceTypes: true,
});

type blockContactProps = {
visitorId: string;
};

const blockContactSchema = {
type: 'object',
properties: {
contactId: {
type: 'string',
},
visitorId: {
type: 'string',
},
},
required: ['contactId', 'visitorId'],
additionalProperties: false,
};

const isBlockContactProps = ajv.compile<blockContactProps>(blockContactSchema);

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface Endpoints {
'/v1/omnichannel/contacts.block': {
POST: (params: blockContactProps) => void;
};
'/v1/omnichannel/contacts.unblock': {
POST: (params: blockContactProps) => void;
};
}
}

API.v1.addRoute(
'omnichannel/contacts.block',
{
authRequired: true,
permissionsRequired: ['block-livechat-contact'],
validateParams: isBlockContactProps,
},
{
async post() {
ensureSingleContactLicense();
const { visitorId } = this.bodyParams;
const { user } = this;

await changeContactBlockStatus({
visitorId,
block: true,
});

await closeBlockedRoom(visitorId, user);

return API.v1.success();
},
},
);

API.v1.addRoute(
'omnichannel/contacts.unblock',
{
authRequired: true,
permissionsRequired: ['unblock-livechat-contact'],
validateParams: isBlockContactProps,
},
{
async post() {
ensureSingleContactLicense();
const { visitorId } = this.bodyParams;

await changeContactBlockStatus({
visitorId,
block: false,
});

return API.v1.success();
},
},
);
1 change: 1 addition & 0 deletions apps/meteor/ee/app/livechat-enterprise/server/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ import './rooms';
import './transcript';
import './reports';
import './triggers';
import './contacts';
36 changes: 36 additions & 0 deletions apps/meteor/ee/app/livechat-enterprise/server/api/lib/contacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { IUser } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { LivechatContacts, LivechatRooms, LivechatVisitors } from '@rocket.chat/models';

import { Livechat } from '../../../../../../app/livechat/server/lib/LivechatTyped';
import { i18n } from '../../../../../../server/lib/i18n';

export async function changeContactBlockStatus({ block, visitorId }: { visitorId: string; block: boolean }) {
const result = await LivechatContacts.updateContactChannel(visitorId, { blocked: block });

if (!result.modifiedCount) {
throw new Error('error-contact-not-found');
}
}

export function ensureSingleContactLicense() {
if (!License.hasModule('contact-id-verification')) {
throw new Error('error-action-not-allowed');
}
}

export async function closeBlockedRoom(visitorId: string, user: IUser) {
const visitor = await LivechatVisitors.findOneById(visitorId);

if (!visitor) {
throw new Error('error-visitor-not-found');
}

const room = await LivechatRooms.findOneOpenByVisitorToken(visitor.token);

if (!room) {
return;
}

return Livechat.closeRoom({ room, visitor, comment: i18n.t('close-blocked-room-comment'), user });
}
2 changes: 2 additions & 0 deletions apps/meteor/ee/app/livechat-enterprise/server/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const omnichannelEEPermissions = [
{ _id: 'outbound-voip-calls', roles: [adminRole, livechatManagerRole] },
{ _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] },
];

export const createPermissions = async (): Promise<void> => {
Expand Down
19 changes: 12 additions & 7 deletions apps/meteor/ee/server/patches/verifyContactChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ export const runVerifyContactChannel = async (
): Promise<ILivechatContact | null> => {
const { contactId, field, value, visitorId, roomId } = params;

await LivechatContacts.updateContactChannel(contactId, visitorId, {
'unknown': false,
'channels.$.verified': true,
'channels.$.verifiedAt': new Date(),
'channels.$.field': field,
'channels.$.value': value,
});
await LivechatContacts.updateContactChannel(
visitorId,
{
verified: true,
verifiedAt: new Date(),
field,
value,
},
{
unknown: false,
},
);

await LivechatRooms.update({ _id: roomId }, { $set: { verified: true } });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,14 @@ describe('verifyContactChannel', () => {

expect(
modelsMock.LivechatContacts.updateContactChannel.calledOnceWith(
'contactId',
'visitorId',
sinon.match({
'unknown': false,
'channels.$.verified': true,
'channels.$.field': 'field',
'channels.$.value': 'value',
verified: true,
field: 'field',
value: 'value',
}),
sinon.match({
unknown: false,
}),
),
).to.be.true;
Expand Down
41 changes: 32 additions & 9 deletions apps/meteor/server/models/raw/LivechatContacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,6 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL
return updatedValue.value as ILivechatContact;
}

async updateContactChannel(contactId: string, visitorId: string, data: UpdateFilter<ILivechatContact>['$set']): Promise<UpdateResult> {
return this.updateOne(
{ '_id': contactId, 'channels.visitorId': visitorId },
{
$set: data,
},
);
}

findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated<FindCursor<ILivechatContact>> {
const searchRegex = escapeRegExp(searchText || '');
const match: Filter<ILivechatContact & RootFilterOperators<ILivechatContact>> = {
Expand Down Expand Up @@ -157,6 +148,38 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL
return this.updateOne({ '_id': contactId, 'channels.visitorId': visitorId }, { $set: { lastChat, 'channels.$.lastChat': lastChat } });
}

async isChannelBlocked(visitorId: ILivechatVisitor['_id']): Promise<boolean> {
return Boolean(
await this.findOne(
{
'channels.visitorId': visitorId,
'channels.blocked': true,
},
{ projection: { _id: 1 } },
),
);
}

async updateContactChannel(
visitorId: ILivechatVisitor['_id'],
data: Partial<ILivechatContactChannel>,
contactData?: Partial<Omit<ILivechatContact, 'channels'>>,
): Promise<UpdateResult> {
return this.updateOne(
{
'channels.visitorId': visitorId,
},
{
$set: {
...contactData,
...(Object.fromEntries(
Object.keys(data).map((key) => [`channels.$.${key}`, data[key as keyof ILivechatContactChannel]]),
) as UpdateFilter<ILivechatContact>['$set']),
},
},
);
}

async findSimilarVerifiedContacts(
{ field, value }: Pick<ILivechatContactChannel, 'field' | 'value'>,
originalContactId: string,
Expand Down
Loading

0 comments on commit b47fb3c

Please sign in to comment.