Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
28104b0
feat: adds new model func to disable Contacts by id
lucas-a-pelegrino Jul 31, 2025
f8c1291
feat: adds new endpoint to remove a contact by its id
lucas-a-pelegrino Jul 31, 2025
a08f491
tests: adds unit testing for disableContactById
lucas-a-pelegrino Aug 1, 2025
af2a780
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
lucas-a-pelegrino Aug 1, 2025
1fe2302
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
lucas-a-pelegrino Aug 4, 2025
c1b1c7f
tests: adds e2e testing for removing a contact
lucas-a-pelegrino Aug 4, 2025
047e9b9
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
lucas-a-pelegrino Aug 5, 2025
9acd054
tests: adds improvements to e2e testing
lucas-a-pelegrino Aug 5, 2025
d7bfad8
feat: adds new permission and setting to handle livechat contact removal
lucas-a-pelegrino Aug 5, 2025
224adf4
tests: adds setting check for unit/e2e tests
lucas-a-pelegrino Aug 5, 2025
0ed996a
docs: adds .changeset
lucas-a-pelegrino Aug 5, 2025
a60636a
Merge branch 'develop' into feat/CTZ-173
lucas-a-pelegrino Aug 6, 2025
4466e37
chore: addresses pull request requested changes
lucas-a-pelegrino Aug 11, 2025
f151d93
chore: addresses further PR requested changes
lucas-a-pelegrino Aug 11, 2025
c6a273c
chore: attempt at fixing endpoint action response types
lucas-a-pelegrino Aug 11, 2025
be4c961
chore: attempt at fixing endpoint action response types #2
lucas-a-pelegrino Aug 12, 2025
20800f3
chore: uses general response validators
lucas-a-pelegrino Aug 12, 2025
71a8091
fix types
KevLehman Aug 12, 2025
9e476ce
chore: adds 403 resposne validation
lucas-a-pelegrino Aug 12, 2025
d5f8bec
chore: removes unused schema validators
lucas-a-pelegrino Aug 12, 2025
30ec706
tests: improves after logic to clean rooms
lucas-a-pelegrino Aug 12, 2025
f8fbb23
tests: moves disableContact.spec.ts to tests/unit/
lucas-a-pelegrino Aug 12, 2025
a99b1d5
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
lucas-a-pelegrino Aug 13, 2025
edffaff
fix: merge conflicts
lucas-a-pelegrino Aug 13, 2025
306b44b
chore: updates .changeset to remove new setting mention
lucas-a-pelegrino Aug 14, 2025
9be8d70
tests: removes old setting mock logic
lucas-a-pelegrino Aug 14, 2025
0010981
chore: adds minor improvements and fixes
lucas-a-pelegrino Aug 14, 2025
4a7589d
chore: removes NotFound exception to adhere to v1 designs
lucas-a-pelegrino Aug 14, 2025
d2281c1
tests: removes contact not found 404 test suite
lucas-a-pelegrino Aug 15, 2025
d28fd32
tests: minor improvements to test suite
lucas-a-pelegrino Aug 18, 2025
48195f9
tests: removes .only call
lucas-a-pelegrino Aug 18, 2025
02baa51
tests: removes duplicate code
lucas-a-pelegrino Aug 18, 2025
849626c
chore: updates model interface func return type to match actual model…
lucas-a-pelegrino Aug 18, 2025
742bd94
chore: improves logic to handle contacts visitors as well
lucas-a-pelegrino Aug 18, 2025
25831e8
Merge branch 'feat/CTZ-173' of github.com:RocketChat/Rocket.Chat into…
lucas-a-pelegrino Aug 18, 2025
ee80cd0
chore: adds GDPR check logic and improves unit tests
lucas-a-pelegrino Aug 20, 2025
3226609
chore: adds api test coverage for GDPR rules check
lucas-a-pelegrino Aug 20, 2025
03be304
Merge branch 'develop' of github.com:RocketChat/Rocket.Chat into feat…
lucas-a-pelegrino Aug 20, 2025
442a446
fix: removeGuest call sequence
lucas-a-pelegrino Aug 20, 2025
d8b940e
fix: removeGuest call sequence old code
lucas-a-pelegrino Aug 20, 2025
9a07659
fix: remove .only
lucas-a-pelegrino Aug 20, 2025
689df06
chore: replaces findOpenByContactId by countOpenByContact
lucas-a-pelegrino Aug 21, 2025
e3533e8
Revert "chore: replaces findOpenByContactId by countOpenByContact"
lucas-a-pelegrino Aug 21, 2025
4ccc52b
chore: replaces findOpenByContactId by findOne
lucas-a-pelegrino Aug 21, 2025
5c8ef7e
fix: findOne query params on disableContact
lucas-a-pelegrino Aug 21, 2025
cb7f89d
chore: abstracts findOne into a new model func
lucas-a-pelegrino Aug 21, 2025
8cceade
Merge branch 'develop' into feat/CTZ-173
kodiakhq[bot] Aug 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/five-carpets-perform.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 4 additions & 0 deletions apps/meteor/app/authorization/server/constant/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
43 changes: 43 additions & 0 deletions apps/meteor/app/livechat/server/api/v1/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,22 @@ 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';
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';
Expand Down Expand Up @@ -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 {}
}
23 changes: 23 additions & 0 deletions apps/meteor/app/livechat/server/lib/contacts/disableContact.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 1 addition & 1 deletion apps/meteor/app/livechat/server/lib/guests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
99 changes: 99 additions & 0 deletions apps/meteor/tests/end-to-end/api/livechat/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
});
});
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
1 change: 1 addition & 0 deletions packages/model-typings/src/models/ILivechatRoomsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}
18 changes: 18 additions & 0 deletions packages/models/src/models/LivechatContacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } });

Expand Down
4 changes: 4 additions & 0 deletions packages/models/src/models/LivechatRooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
}
}
Loading
Loading