feat: adds new endpoint to disable Contacts by id (#36589)

Co-authored-by: Kevin Aleman <11577696+KevLehman@users.noreply.github.com>
pull/36776/head^2
Lucas Pelegrino 8 months ago committed by GitHub
parent cad0688f63
commit 3e177dbd0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      .changeset/five-carpets-perform.md
  2. 4
      apps/meteor/app/authorization/server/constant/permissions.ts
  3. 43
      apps/meteor/app/livechat/server/api/v1/contact.ts
  4. 23
      apps/meteor/app/livechat/server/lib/contacts/disableContact.ts
  5. 2
      apps/meteor/app/livechat/server/lib/guests.ts
  6. 99
      apps/meteor/tests/end-to-end/api/livechat/contacts.ts
  7. 105
      apps/meteor/tests/unit/app/livechat/server/lib/disableContact.spec.ts
  8. 1
      packages/i18n/src/locales/en.i18n.json
  9. 1
      packages/model-typings/src/models/ILivechatContactsModel.ts
  10. 1
      packages/model-typings/src/models/ILivechatRoomsModel.ts
  11. 18
      packages/models/src/models/LivechatContacts.ts
  12. 4
      packages/models/src/models/LivechatRooms.ts
  13. 30
      packages/rest-typings/src/v1/omnichannel.ts

@ -0,0 +1,9 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
"@rocket.chat/model-typings": minor
"@rocket.chat/models": minor
"@rocket.chat/rest-typings": minor
---
Adds new endpoint to disable Livechat Contacts by its id, with a new permission `delete-livechat-contact`.

@ -106,6 +106,10 @@ export const permissions = [
_id: 'view-livechat-contact',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{
_id: 'delete-livechat-contact',
roles: ['livechat-manager', 'admin'],
},
{
_id: 'view-livechat-contact-history',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],

@ -8,6 +8,11 @@ import {
isGETOmnichannelContactsSearchProps,
isGETOmnichannelContactsCheckExistenceProps,
isPOSTOmnichannelContactsConflictsProps,
isPOSTOmnichannelContactDeleteProps,
POSTOmnichannelContactDeleteSuccessSchema,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { removeEmpty } from '@rocket.chat/tools';
@ -15,8 +20,10 @@ import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { API } from '../../../../api/server';
import type { ExtractRoutesFromAPI } from '../../../../api/server/ApiClass';
import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems';
import { createContact } from '../../lib/contacts/createContact';
import { disableContactById } from '../../lib/contacts/disableContact';
import { getContactChannelsGrouped } from '../../lib/contacts/getContactChannelsGrouped';
import { getContactHistory } from '../../lib/contacts/getContactHistory';
import { getContacts } from '../../lib/contacts/getContacts';
@ -224,3 +231,39 @@ API.v1.addRoute(
},
},
);
const omnichannelContactsEndpoints = API.v1.post(
'omnichannel/contacts.delete',
{
response: {
200: POSTOmnichannelContactDeleteSuccessSchema,
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
authRequired: true,
permissionsRequired: ['delete-livechat-contact'],
body: isPOSTOmnichannelContactDeleteProps,
},
async function action() {
const { contactId } = this.bodyParams;
try {
await disableContactById(contactId);
return API.v1.success();
} catch (error) {
if (!(error instanceof Error)) {
return API.v1.failure('error-invalid-contact');
}
return API.v1.failure(error.message);
}
},
);
type OmnichannelContactsEndpoints = ExtractRoutesFromAPI<typeof omnichannelContactsEndpoints>;
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends OmnichannelContactsEndpoints {}
}

@ -0,0 +1,23 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { LivechatContacts, LivechatRooms } from '@rocket.chat/models';
import { settings } from '../../../../settings/server';
import { removeGuest } from '../guests';
export async function disableContactById(contactId: string): Promise<void> {
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id' | 'channels'>>(contactId);
if (!contact) {
throw new Error('error-contact-not-found');
}
// Checking if the contact has any open channel/room before removing its data.
const contactOpenRooms = await LivechatRooms.checkContactOpenRooms(contactId);
if (contactOpenRooms && !settings.get<boolean>('Livechat_Allow_collect_and_store_HTTP_header_informations')) {
throw new Error('error-contact-has-open-rooms');
}
// Cleaning contact/visitor data;
await Promise.all(contact.channels.map((channel) => removeGuest({ _id: channel.visitor.visitorId })));
await LivechatContacts.disableByContactId(contactId);
}

@ -74,7 +74,7 @@ export async function saveGuest(
return ret;
}
async function removeGuest({ _id }: { _id: string }) {
export async function removeGuest({ _id }: { _id: string }) {
await cleanGuestHistory(_id);
return LivechatVisitors.disableById(_id);
}

@ -1073,6 +1073,105 @@ describe('LIVECHAT - contacts', () => {
});
});
describe('[POST] omnichannel/contacts.delete', () => {
let contactId: string;
let roomId: string;
const email = faker.internet.email().toLowerCase();
const phone = faker.phone.number();
const contact = {
name: faker.person.fullName(),
emails: [email],
phones: [phone],
contactManager: agentUser?._id,
};
before(async () => {
await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', true);
const { body } = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({ ...contact });
contactId = body.contactId;
const visitor = await createVisitor(undefined, contact.name, email, phone);
const room = await createLivechatRoom(visitor.token);
roomId = room._id;
});
after(async () => {
await closeOmnichannelRoom(roomId);
});
it('should be able to disable a contact by its id', async () => {
const response = await request.post(api(`omnichannel/contacts.delete`)).set(credentials).send({ contactId });
expect(response.status).to.be.equal(200);
expect(response.body).to.have.property('success', true);
});
it('should return an error if the contact is not found', async () => {
const response = await request.post(api(`omnichannel/contacts.delete`)).set(credentials).send({ contactId });
expect(response.status).to.be.equal(400);
expect(response.body).to.have.property('success', false);
expect(response.body.error).to.be.equal('error-contact-not-found');
});
describe('[PERMISSIONS] omnichannel/contacts.delete', () => {
before(async () => {
await removePermissionFromAllRoles('delete-livechat-contact');
});
after(async () => {
await restorePermissionToRoles('delete-livechat-contact');
});
it("should return an error if user doesn't have 'delete-livechat-contact' permission", async () => {
const response = await request.post(api(`omnichannel/contacts.delete`)).set(credentials).send({ contactId });
expect(response.status).to.be.equal(403);
expect(response.body).to.have.property('success', false);
expect(response.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]');
});
});
describe('[GDPR Setting] omnichannel/contacts.delete', () => {
before(async () => {
await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', false);
const { body } = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({ ...contact });
contactId = body.contactId;
const visitor = await createVisitor(undefined, contact.name, email, phone);
const room = await createLivechatRoom(visitor.token);
roomId = room._id;
});
after(async () => {
await updateSetting('Livechat_Allow_collect_and_store_HTTP_header_informations', true);
});
it("should not delete the contact if the GDPR setting isn't enabled and contact has open rooms", async () => {
const response = await request.post(api(`omnichannel/contacts.delete`)).set(credentials).send({ contactId });
expect(response.status).to.be.equal(400);
expect(response.body).to.have.property('success', false);
expect(response.body.error).to.be.equal('error-contact-has-open-rooms');
const contactCheck = await request.get(api('omnichannel/contacts.get')).set(credentials).query({ contactId });
expect(contactCheck.status).to.be.equal(200);
});
});
});
describe('[GET] omnichannel/contacts.checkExistence', () => {
let contactId: string;
let roomId: string;

@ -0,0 +1,105 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
findOneEnabledById: sinon.stub(),
disableByContactId: sinon.stub(),
},
LivechatRooms: {
checkContactOpenRooms: sinon.stub(),
},
};
const settingsMock = {
get: sinon.stub(),
};
const removeGuestMock = { removeGuest: sinon.stub() };
const { disableContactById } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/contacts/disableContact.ts', {
'@rocket.chat/models': modelsMock,
'../guests': removeGuestMock,
'../../../../settings/server': { settings: settingsMock },
});
describe('disableContact', () => {
const contact = {
_id: 'contact-id',
channels: [
{
visitor: {
visitorId: 'visitor-id',
},
},
],
};
beforeEach(() => {
modelsMock.LivechatContacts.findOneEnabledById.reset();
modelsMock.LivechatRooms.checkContactOpenRooms.reset();
modelsMock.LivechatContacts.disableByContactId.reset();
settingsMock.get.reset();
removeGuestMock.removeGuest.reset();
});
it('should disable the contact', async () => {
settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(true);
modelsMock.LivechatContacts.findOneEnabledById.resolves(contact);
modelsMock.LivechatRooms.checkContactOpenRooms.resolves(null);
removeGuestMock.removeGuest.resolves();
modelsMock.LivechatContacts.disableByContactId.resolves();
await disableContactById(contact._id);
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true;
expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true;
expect(removeGuestMock.removeGuest.calledOnceWith({ _id: 'visitor-id' })).to.be.true;
expect(modelsMock.LivechatContacts.disableByContactId.calledOnceWith(contact._id)).to.be.true;
});
it('should call removeGuest for each channel the contact has communicated from', async () => {
contact.channels.push({ visitor: { visitorId: 'visitor-id-2' } });
settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(true);
modelsMock.LivechatContacts.findOneEnabledById.resolves(contact);
modelsMock.LivechatRooms.checkContactOpenRooms.resolves(null);
removeGuestMock.removeGuest.resolves();
modelsMock.LivechatContacts.disableByContactId.resolves();
await disableContactById(contact._id);
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true;
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith(contact._id)).to.be.true;
expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true;
expect(removeGuestMock.removeGuest.calledTwice).to.be.true;
expect(removeGuestMock.removeGuest.getCall(0).args[0]).to.deep.equal({ _id: 'visitor-id' });
expect(removeGuestMock.removeGuest.getCall(1).args[0]).to.deep.equal({ _id: 'visitor-id-2' });
expect(modelsMock.LivechatContacts.disableByContactId.calledOnceWith(contact._id)).to.be.true;
});
it('should throw error if contact is not found', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves(null);
await expect(disableContactById('nonexistent-contact-id')).to.be.rejectedWith('error-contact-not-found');
expect(modelsMock.LivechatContacts.findOneEnabledById.calledOnceWith('nonexistent-contact-id')).to.be.true;
expect(modelsMock.LivechatRooms.checkContactOpenRooms.notCalled).to.be.true;
expect(removeGuestMock.removeGuest.notCalled).to.be.true;
expect(modelsMock.LivechatContacts.disableByContactId.notCalled).to.be.true;
});
it('should throw error if contact has open rooms and GDPR is disabled', async () => {
settingsMock.get.withArgs('Livechat_Allow_collect_and_store_HTTP_header_informations').returns(false);
modelsMock.LivechatContacts.findOneEnabledById.resolves(contact);
modelsMock.LivechatRooms.checkContactOpenRooms.resolves({ _id: 'room-id' });
modelsMock.LivechatContacts.disableByContactId.resolves();
await expect(disableContactById(contact._id)).to.be.rejectedWith('error-contact-has-open-rooms');
expect(modelsMock.LivechatRooms.checkContactOpenRooms.calledOnceWith(contact._id)).to.be.true;
expect(removeGuestMock.removeGuest.notCalled).to.be.true;
expect(modelsMock.LivechatContacts.disableByContactId.notCalled).to.be.true;
});
});

@ -5981,6 +5981,7 @@
"delete-team_description": "Permission to delete teams",
"delete-user": "Delete User",
"delete-user_description": "Permission to delete users",
"delete-livechat-contact": "Delete Omnichannel Contact",
"different_values_found": "{{number}} different values found",
"disabled": "disabled",
"discussion-created": "{{message}}",

@ -71,6 +71,7 @@ export interface ILivechatContactsModel extends IBaseModel<ILivechatContact> {
getStatistics(): AggregationCursor<{ totalConflicts: number; avgChannelsPerContact: number }>;
updateByVisitorId(visitorId: string, update: UpdateFilter<ILivechatContact>, options?: UpdateOptions): Promise<UpdateResult>;
disableByVisitorId(visitorId: string): Promise<UpdateResult | Document>;
disableByContactId(contactId: string): Promise<UpdateResult>;
findOneEnabledById(_id: ILivechatContact['_id'], options?: FindOptions<ILivechatContact>): Promise<ILivechatContact | null>;
findOneEnabledById<P extends Document = ILivechatContact>(_id: P['_id'], options?: FindOptions<P>): Promise<P | null>;
findOneEnabledById(_id: ILivechatContact['_id'], options?: any): Promise<ILivechatContact | null>;

@ -294,4 +294,5 @@ export interface ILivechatRoomsModel extends IBaseModel<IOmnichannelRoom> {
contact: Partial<Pick<ILivechatContact, '_id' | 'name'>>,
): Promise<UpdateResult | Document>;
findOpenByContactId(contactId: ILivechatContact['_id'], options?: FindOptions<IOmnichannelRoom>): FindCursor<IOmnichannelRoom>;
checkContactOpenRooms(contactId: ILivechatContact['_id']): Promise<IOmnichannelRoom | null>;
}

@ -331,6 +331,24 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL
);
}
disableByContactId(contactId: string): Promise<UpdateResult> {
return this.updateOne(
{ _id: contactId },
{
$set: { enabled: false },
$unset: {
emails: 1,
customFields: 1,
lastChat: 1,
channels: 1,
name: 1,
phones: 1,
conflictingFields: 1,
},
},
);
}
async addEmail(contactId: string, email: string): Promise<ILivechatContact | null> {
const updatedContact = await this.findOneAndUpdate({ _id: contactId }, { $addToSet: { emails: { address: email } } });

@ -2808,4 +2808,8 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
findOpenByContactId(contactId: ILivechatContact['_id'], options?: FindOptions<IOmnichannelRoom>): FindCursor<IOmnichannelRoom> {
return this.find({ open: true, contactId }, options);
}
checkContactOpenRooms(contactId: ILivechatContact['_id']): Promise<IOmnichannelRoom | null> {
return this.findOne({ contactId, open: true }, { projection: { _id: 1 } });
}
}

@ -1389,6 +1389,36 @@ const POSTUpdateOmnichannelContactsSchema = {
export const isPOSTUpdateOmnichannelContactsProps = ajv.compile<POSTUpdateOmnichannelContactsProps>(POSTUpdateOmnichannelContactsSchema);
type POSTOmnichannelContactDeleteProps = {
contactId: string;
};
const POSTOmnichannelContactDeleteSchema = {
type: 'object',
properties: {
contactId: {
type: 'string',
},
},
required: ['contactId'],
additionalProperties: false,
};
export const isPOSTOmnichannelContactDeleteProps = ajv.compile<POSTOmnichannelContactDeleteProps>(POSTOmnichannelContactDeleteSchema);
const POSTOmnichannelContactDeleteSuccess = {
type: 'object',
properties: {
success: {
type: 'boolean',
enum: [true],
},
},
additionalProperties: false,
};
export const POSTOmnichannelContactDeleteSuccessSchema = ajv.compile<void>(POSTOmnichannelContactDeleteSuccess);
type POSTOmnichannelContactsConflictsProps = {
contactId: string;
name?: string;

Loading…
Cancel
Save