diff --git a/.changeset/five-carpets-perform.md b/.changeset/five-carpets-perform.md new file mode 100644 index 00000000000..28763e43e08 --- /dev/null +++ b/.changeset/five-carpets-perform.md @@ -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`. diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 7e01dcb92f9..9bf8c84ce50 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -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'], diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 76ef84383a1..f5c6fe87f3f 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -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; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends OmnichannelContactsEndpoints {} +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/disableContact.ts b/apps/meteor/app/livechat/server/lib/contacts/disableContact.ts new file mode 100644 index 00000000000..340d34e6906 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/disableContact.ts @@ -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 { + const contact = await LivechatContacts.findOneEnabledById>(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('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); +} diff --git a/apps/meteor/app/livechat/server/lib/guests.ts b/apps/meteor/app/livechat/server/lib/guests.ts index 454dc55d812..b4213d8211e 100644 --- a/apps/meteor/app/livechat/server/lib/guests.ts +++ b/apps/meteor/app/livechat/server/lib/guests.ts @@ -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); } diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts index a78a0742696..43ba8f5b67a 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -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; diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/disableContact.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/disableContact.spec.ts new file mode 100644 index 00000000000..aca864f9aeb --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/disableContact.spec.ts @@ -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; + }); +}); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 8c2f9ca1bf3..6a397bad2a6 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -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}}", diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts index 237ad953d8a..5d6069c03a2 100644 --- a/packages/model-typings/src/models/ILivechatContactsModel.ts +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -71,6 +71,7 @@ export interface ILivechatContactsModel extends IBaseModel { getStatistics(): AggregationCursor<{ totalConflicts: number; avgChannelsPerContact: number }>; updateByVisitorId(visitorId: string, update: UpdateFilter, options?: UpdateOptions): Promise; disableByVisitorId(visitorId: string): Promise; + disableByContactId(contactId: string): Promise; findOneEnabledById(_id: ILivechatContact['_id'], options?: FindOptions): Promise; findOneEnabledById

(_id: P['_id'], options?: FindOptions

): Promise

; findOneEnabledById(_id: ILivechatContact['_id'], options?: any): Promise; diff --git a/packages/model-typings/src/models/ILivechatRoomsModel.ts b/packages/model-typings/src/models/ILivechatRoomsModel.ts index 47d38e0e307..d159e5307aa 100644 --- a/packages/model-typings/src/models/ILivechatRoomsModel.ts +++ b/packages/model-typings/src/models/ILivechatRoomsModel.ts @@ -294,4 +294,5 @@ export interface ILivechatRoomsModel extends IBaseModel { contact: Partial>, ): Promise; findOpenByContactId(contactId: ILivechatContact['_id'], options?: FindOptions): FindCursor; + checkContactOpenRooms(contactId: ILivechatContact['_id']): Promise; } diff --git a/packages/models/src/models/LivechatContacts.ts b/packages/models/src/models/LivechatContacts.ts index c1e0c47098f..f5c0b605dee 100644 --- a/packages/models/src/models/LivechatContacts.ts +++ b/packages/models/src/models/LivechatContacts.ts @@ -331,6 +331,24 @@ export class LivechatContactsRaw extends BaseRaw implements IL ); } + disableByContactId(contactId: string): Promise { + 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 { const updatedContact = await this.findOneAndUpdate({ _id: contactId }, { $addToSet: { emails: { address: email } } }); diff --git a/packages/models/src/models/LivechatRooms.ts b/packages/models/src/models/LivechatRooms.ts index e9fdef490b1..2f0a28eae80 100644 --- a/packages/models/src/models/LivechatRooms.ts +++ b/packages/models/src/models/LivechatRooms.ts @@ -2808,4 +2808,8 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive findOpenByContactId(contactId: ILivechatContact['_id'], options?: FindOptions): FindCursor { return this.find({ open: true, contactId }, options); } + + checkContactOpenRooms(contactId: ILivechatContact['_id']): Promise { + return this.findOne({ contactId, open: true }, { projection: { _id: 1 } }); + } } diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index c51c7f6134c..551c3290304 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1389,6 +1389,36 @@ const POSTUpdateOmnichannelContactsSchema = { export const isPOSTUpdateOmnichannelContactsProps = ajv.compile(POSTUpdateOmnichannelContactsSchema); +type POSTOmnichannelContactDeleteProps = { + contactId: string; +}; + +const POSTOmnichannelContactDeleteSchema = { + type: 'object', + properties: { + contactId: { + type: 'string', + }, + }, + required: ['contactId'], + additionalProperties: false, +}; + +export const isPOSTOmnichannelContactDeleteProps = ajv.compile(POSTOmnichannelContactDeleteSchema); + +const POSTOmnichannelContactDeleteSuccess = { + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + additionalProperties: false, +}; + +export const POSTOmnichannelContactDeleteSuccessSchema = ajv.compile(POSTOmnichannelContactDeleteSuccess); + type POSTOmnichannelContactsConflictsProps = { contactId: string; name?: string;