Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/eighty-pumas-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

fix(rooms): Update lastMessage to previous valid message on user deletion
358 changes: 195 additions & 163 deletions apps/meteor/app/lib/server/functions/deleteUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import { Apps, AppEvents } from '@rocket.chat/apps';
import { api } from '@rocket.chat/core-services';
import { isUserFederated, type IUser } from '@rocket.chat/core-typings';
import {
Integrations,
FederationServers,
LivechatVisitors,
LivechatDepartmentAgents,
Messages,
Rooms,
Subscriptions,
Users,
ReadReceipts,
LivechatUnitMonitors,
ModerationReports,
Integrations,
FederationServers,
LivechatVisitors,
LivechatDepartmentAgents,
Messages,
Rooms,
Subscriptions,
Users,
ReadReceipts,
LivechatUnitMonitors,
ModerationReports,
} from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';

Expand All @@ -25,158 +25,190 @@ import { i18n } from '../../../../server/lib/i18n';
import { FileUpload } from '../../../file-upload/server';
import { settings } from '../../../settings/server';
import {
notifyOnRoomChangedById,
notifyOnIntegrationChangedByUserId,
notifyOnLivechatDepartmentAgentChanged,
notifyOnUserChange,
notifyOnRoomChangedById,
notifyOnIntegrationChangedByUserId,
notifyOnLivechatDepartmentAgentChanged,
notifyOnUserChange,
} from '../lib/notifyListener';

export async function deleteUser(userId: string, confirmRelinquish = false, deletedBy?: IUser['_id']): Promise<{ deletedRooms: string[] }> {
if (userId === 'rocket.cat') {
throw new Meteor.Error('error-action-not-allowed', 'Deleting the rocket.cat user is not allowed', {
method: 'deleteUser',
action: 'Delete_user',
});
}

const user = await Users.findOneById(userId, {
projection: { username: 1, avatarOrigin: 1, roles: 1, federated: 1 },
});

if (!user) {
return { deletedRooms: [] };
}

if (isUserFederated(user)) {
throw new Meteor.Error('error-not-allowed', 'User participated in federation, this user can only be deactivated permanently', {
method: 'deleteUser',
});
}

const subscribedRooms = await getSubscribedRoomsForUserWithDetails(userId);

if (shouldRemoveOrChangeOwner(subscribedRooms) && !confirmRelinquish) {
const rooms = await getUserSingleOwnedRooms(subscribedRooms);
throw new Meteor.Error('user-last-owner', '', rooms);
}

let deletedRooms: string[] = [];
// Users without username can't do anything, so there is nothing to remove
if (user.username != null) {
let userToReplaceWhenUnlinking: IUser | null = null;
const nameAlias = i18n.t('Removed_User');
deletedRooms = await relinquishRoomOwnerships(userId, subscribedRooms, true);

const messageErasureType = settings.get<'Delete' | 'Unlink' | 'Keep'>('Message_ErasureType');
switch (messageErasureType) {
case 'Delete':
const store = FileUpload.getStore('Uploads');
const cursor = Messages.findFilesByUserId(userId);

for await (const { file } of cursor) {
if (!file) {
continue;
}
await store.deleteById(file._id);
}

await Messages.removeByUserId(userId);
await ReadReceipts.removeByUserId(userId);

await ModerationReports.hideMessageReportsByUserId(
userId,
deletedBy || userId,
deletedBy === userId ? 'user deleted own account' : 'user account deleted',
'DELETE_USER',
);

break;
case 'Unlink':
userToReplaceWhenUnlinking = await Users.findOneById('rocket.cat');
if (!userToReplaceWhenUnlinking?._id || !userToReplaceWhenUnlinking?.username) {
break;
}
await Messages.unlinkUserId(userId, userToReplaceWhenUnlinking?._id, userToReplaceWhenUnlinking?.username, nameAlias);
break;
}

await Rooms.updateGroupDMsRemovingUsernamesByUsername(user.username, userId); // Remove direct rooms with the user
await Rooms.removeDirectRoomContainingUsername(user.username); // Remove direct rooms with the user

const rids = subscribedRooms.map((room) => room.rid);
void notifyOnRoomChangedById(rids);

await Subscriptions.removeByUserId(userId);

// Remove user as livechat agent
if (user.roles.includes('livechat-agent')) {
const departmentAgents = await LivechatDepartmentAgents.findByAgentId(userId).toArray();

const { deletedCount } = await LivechatDepartmentAgents.removeByAgentId(userId);

if (deletedCount > 0) {
departmentAgents.forEach((depAgent) => {
void notifyOnLivechatDepartmentAgentChanged(
{
_id: depAgent._id,
agentId: userId,
departmentId: depAgent.departmentId,
},
'removed',
);
});
}
}

if (user.roles.includes('livechat-monitor')) {
// Remove user as Unit Monitor
await LivechatUnitMonitors.removeByMonitorId(userId);
}

// This is for compatibility. Since we allowed any user to be contact manager b4, we need to have the same logic
// for deletion.
await LivechatVisitors.removeContactManagerByUsername(user.username);

// removes user's avatar
if (user.avatarOrigin === 'upload' || user.avatarOrigin === 'url' || user.avatarOrigin === 'rest') {
await FileUpload.getStore('Avatars').deleteByName(user.username);
}

// Disables all the integrations which rely on the user being deleted.
await Integrations.disableByUserId(userId);
void notifyOnIntegrationChangedByUserId(userId);

// Don't broadcast user.deleted for Erasure Type of 'Keep' so that messages don't disappear from logged in sessions
if (messageErasureType === 'Delete') {
void api.broadcast('user.deleted', user, {
messageErasureType,
});
}
if (messageErasureType === 'Unlink' && userToReplaceWhenUnlinking) {
void api.broadcast('user.deleted', user, {
messageErasureType,
replaceByUser: { _id: userToReplaceWhenUnlinking._id, username: userToReplaceWhenUnlinking?.username, alias: nameAlias },
});
}
}

// Remove user from users database
await Users.removeById(userId);

// App IPostUserDeleted event hook
if (deletedBy) {
await Apps.self?.triggerEvent(AppEvents.IPostUserDeleted, { user, performedBy: await Users.findOneById(deletedBy) });
}

// update name and fname of group direct messages
await updateGroupDMsName(user);

// Refresh the servers list
await FederationServers.refreshServers();

void notifyOnUserChange({ clientAction: 'removed', id: user._id });

await callbacks.run('afterDeleteUser', user);

return { deletedRooms };
}
if (userId === 'rocket.cat') {
throw new Meteor.Error('error-action-not-allowed', 'Deleting the rocket.cat user is not allowed', {
method: 'deleteUser',
action: 'Delete_user',
});
}

const user = await Users.findOneById(userId, {
projection: { username: 1, avatarOrigin: 1, roles: 1, federated: 1 },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (!user) {
return { deletedRooms: [] };
}

if (isUserFederated(user)) {
throw new Meteor.Error('error-not-allowed', 'User participated in federation, this user can only be deactivated permanently', {
method: 'deleteUser',
});
}

const subscribedRooms = await getSubscribedRoomsForUserWithDetails(userId);

if (shouldRemoveOrChangeOwner(subscribedRooms) && !confirmRelinquish) {
const rooms = await getUserSingleOwnedRooms(subscribedRooms);
throw new Meteor.Error('user-last-owner', '', rooms);
}

let deletedRooms: string[] = [];
const affectedRoomIds: string[] = [];

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
// Users without username can't do anything, so there is nothing to remove
if (user.username != null) {
let userToReplaceWhenUnlinking: IUser | null = null;
const nameAlias = i18n.t('Removed_User');
await relinquishRoomOwnerships(userId, subscribedRooms);

const messageErasureType = settings.get<'Delete' | 'Unlink' | 'Keep'>('Message_ErasureType');
switch (messageErasureType) {
case 'Delete': {
const store = FileUpload.getStore('Uploads');
const cursor = Messages.findFilesByUserId(userId);

for await (const { file } of cursor) {
if (!file) {
continue;
}
await store.deleteById(file._id);
}

await Messages.removeByUserId(userId);

// Find rooms where the user sent the last message
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const roomsToUpdate = await Rooms.find({ 'lastMessage.u._id': userId }, { projection: { _id: 1 } }).toArray();
for await (const room of roomsToUpdate) {
affectedRoomIds.push(room._id);
const [newLastMessage] = await Messages.find({ rid: room._id }, { sort: { ts: -1 }, limit: 1 }).toArray();

if (newLastMessage) {
await Rooms.updateOne({ _id: room._id }, { $set: { lastMessage: newLastMessage } });
} else {
await Rooms.updateOne({ _id: room._id }, { $unset: { lastMessage: 1 } });
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

await ReadReceipts.removeByUserId(userId);

await ModerationReports.hideMessageReportsByUserId(
userId,
deletedBy || userId,
deletedBy === userId ? 'user deleted own account' : 'user account deleted',
'DELETE_USER',
);

break;
}
case 'Unlink':
userToReplaceWhenUnlinking = await Users.findOneById('rocket.cat');
if (!userToReplaceWhenUnlinking?._id || !userToReplaceWhenUnlinking?.username) {
break;
}
await Messages.unlinkUserId(userId, userToReplaceWhenUnlinking?._id, userToReplaceWhenUnlinking?.username, nameAlias);

\ const roomsToUpdateUnlink = await Rooms.find({ 'lastMessage.u._id': userId }, { projection: { _id: 1 } }).toArray();
for await (const room of roomsToUpdateUnlink) {
affectedRoomIds.push(room._id);

\ const [newLastMessage] = await Messages.find({ rid: room._id }, { sort: { ts: -1 }, limit: 1 }).toArray();

if (newLastMessage) {
await Rooms.updateOne({ _id: room._id }, { $set: { lastMessage: newLastMessage } });
} else {
await Rooms.updateOne({ _id: room._id }, { $unset: { lastMessage: 1 } });
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

break;
}

await Rooms.updateGroupDMsRemovingUsernamesByUsername(user.username, userId); // Remove direct rooms with the user
await Rooms.removeDirectRoomContainingUsername(user.username); // Remove direct rooms with the user

const rids = subscribedRooms.map((room) => room.rid);
const allAffectedRids = [...new Set([...rids, ...affectedRoomIds])];
void notifyOnRoomChangedById(allAffectedRids);

await Subscriptions.removeByUserId(userId);

// Remove user as livechat agent
if (user.roles.includes('livechat-agent')) {
const departmentAgents = await LivechatDepartmentAgents.findByAgentId(userId).toArray();

const { deletedCount } = await LivechatDepartmentAgents.removeByAgentId(userId);

if (deletedCount > 0) {
departmentAgents.forEach((depAgent) => {
void notifyOnLivechatDepartmentAgentChanged(
{
_id: depAgent._id,
agentId: userId,
departmentId: depAgent.departmentId,
},
'removed',
);
});
}
}

if (user.roles.includes('livechat-monitor')) {
// Remove user as Unit Monitor
await LivechatUnitMonitors.removeByMonitorId(userId);
}

// This is for compatibility. Since we allowed any user to be contact manager b4, we need to have the same logic
// for deletion.
await LivechatVisitors.removeContactManagerByUsername(user.username);

// removes user's avatar
if (user.avatarOrigin === 'upload' || user.avatarOrigin === 'url' || user.avatarOrigin === 'rest') {
await FileUpload.getStore('Avatars').deleteByName(user.username);
}

// Disables all the integrations which rely on the user being deleted.
await Integrations.disableByUserId(userId);
void notifyOnIntegrationChangedByUserId(userId);

// Don't broadcast user.deleted for Erasure Type of 'Keep' so that messages don't disappear from logged in sessions
if (messageErasureType === 'Delete') {
void api.broadcast('user.deleted', user, {
messageErasureType,
});
}
if (messageErasureType === 'Unlink' && userToReplaceWhenUnlinking) {
void api.broadcast('user.deleted', user, {
messageErasureType,
replaceByUser: { _id: userToReplaceWhenUnlinking._id, username: userToReplaceWhenUnlinking?.username, alias: nameAlias },
});
}
}

// Remove user from users database
await Users.removeById(userId);

// App IPostUserDeleted event hook
if (deletedBy) {
await Apps.self?.triggerEvent(AppEvents.IPostUserDeleted, { user, performedBy: await Users.findOneById(deletedBy) });
}

// update name and fname of group direct messages
await updateGroupDMsName(user);

// Refresh the servers list
await FederationServers.refreshServers();

void notifyOnUserChange({ clientAction: 'removed', id: user._id });

await callbacks.run('afterDeleteUser', user);

return { deletedRooms };
}
4 changes: 4 additions & 0 deletions packages/models/src/models/Rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ export class RoomsRaw extends BaseRaw<IRoom> implements IRoomsModel {
key: { teamMain: 1 },
sparse: true,
},
{
key: { 'lastMessage.u._id': 1 },
sparse: true,
},
];
}

Expand Down