Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4d57df1
Initial plan
Copilot Feb 11, 2026
e261e13
Implement cold storage for read receipts
Copilot Feb 11, 2026
220b629
Update all deletion points to also delete from archive collection
Copilot Feb 11, 2026
2baea07
Address code review feedback: improve type safety and readability
Copilot Feb 11, 2026
6d6f82f
Add configurable settings and tests for read receipts archiving
Copilot Feb 11, 2026
b138dca
Add EE cron tests to jest config and update test configuration
Copilot Feb 11, 2026
e89a105
Move settings watcher to startup to ensure proper initialization
Copilot Feb 11, 2026
4da2663
Add English and Portuguese translations for new archive settings
Copilot Feb 11, 2026
e35b4de
Fix TypeScript type errors in ReadReceiptsArchive model
Copilot Feb 12, 2026
ec19670
Fix lint and TypeScript errors in read receipts archiving
Copilot Feb 12, 2026
c978fab
Add configurable batch processing for read receipts archiving with 1s…
Copilot Feb 12, 2026
5a57b2e
Fix lint issues: import order, await-in-loop, and constant condition
Copilot Feb 12, 2026
d264a0b
Add secondary read preference for archived read receipts queries
Copilot Feb 18, 2026
988db4f
Pass read preference directly to find queries instead of creating sec…
Copilot Feb 18, 2026
9948d14
Fix TypeScript error: call readSecondaryPreferred() without db parameter
Copilot Feb 18, 2026
60efbfc
Do not save extra data for read receipt & do not remove receipts when…
rodrigok Mar 5, 2026
6f23eed
Add setting to enable cold storage
rodrigok Mar 5, 2026
45c33c9
Use concatenation of message id and user id on _id to replace the uni…
rodrigok Mar 5, 2026
dff18c4
Add migration to disable receipts archive if existent dataset is big
rodrigok Mar 7, 2026
0607593
Add alerts for the setting to enable read receipt archive
rodrigok Mar 9, 2026
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
10 changes: 3 additions & 7 deletions apps/meteor/app/lib/server/functions/cleanRoomHistory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { api } from '@rocket.chat/core-services';
import type { IRoom } from '@rocket.chat/core-typings';
import { Messages, Rooms, Subscriptions, ReadReceipts, Users } from '@rocket.chat/models';
import { Messages, Rooms, Subscriptions, ReadReceipts, ReadReceiptsArchive } from '@rocket.chat/models';

import { deleteRoom } from './deleteRoom';
import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../../lib/constants';
Expand Down Expand Up @@ -143,13 +143,9 @@ export async function cleanRoomHistory({
selectedMessageIds,
);

if (!limit) {
const uids = await Users.findByUsernames(fromUsers, { projection: { _id: 1 } })
.map((user) => user._id)
.toArray();
await ReadReceipts.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, uids, ignoreThreads);
} else if (selectedMessageIds) {
if (limit && selectedMessageIds) {
await ReadReceipts.removeByMessageIds(selectedMessageIds);
await ReadReceiptsArchive.removeByMessageIds(selectedMessageIds);
}
Comment on lines +146 to 149
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle the unlimited prune path too.

Line 146 gates receipt cleanup on limit. cleanRoomHistory() also supports deleting every matching message when limit is 0/unset, and in that path both read-receipt collections keep orphaned rows for the removed messages. Please delete archived/hot receipts for the unlimited path as well, either by materializing the IDs before the delete or by adding a filter-based cleanup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/meteor/app/lib/server/functions/cleanRoomHistory.ts` around lines 146 -
149, The cleanup currently only calls ReadReceipts.removeByMessageIds and
ReadReceiptsArchive.removeByMessageIds when `limit && selectedMessageIds`, which
leaves orphaned receipts in the unlimited/delete-all path of
`cleanRoomHistory()`; modify `cleanRoomHistory` so that when the unlimited path
is taken (limit is 0/undefined), you either materialize the IDs of messages
being deleted into `selectedMessageIds` before removing messages, or perform a
filter-based cleanup (e.g., call ReadReceipts.removeByFilter and
ReadReceiptsArchive.removeByFilter with the same room/message predicates) so
archived and hot read receipts are removed in both the limited and unlimited
flows.


if (count) {
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/app/lib/server/functions/deleteMessage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import { api, Message } from '@rocket.chat/core-services';
import { isThreadMessage, type AtLeast, type IMessage, type IRoom, type IThreadMessage, type IUser } from '@rocket.chat/core-typings';
import { Messages, Rooms, Uploads, Users, ReadReceipts, Subscriptions } from '@rocket.chat/models';
import { Messages, Rooms, Uploads, Users, ReadReceipts, ReadReceiptsArchive, Subscriptions } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';

import { callbacks } from '../../../../server/lib/callbacks';
Expand Down Expand Up @@ -69,6 +69,7 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise<voi
await Messages.removeById(message._id);
}
await ReadReceipts.removeByMessageId(message._id);
await ReadReceiptsArchive.removeByMessageId(message._id);

for (const file of files) {
file?._id && (await FileUpload.getStore('Uploads').deleteById(file._id));
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/app/lib/server/functions/deleteUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Subscriptions,
Users,
ReadReceipts,
ReadReceiptsArchive,
LivechatUnitMonitors,
ModerationReports,
} from '@rocket.chat/models';
Expand Down Expand Up @@ -87,6 +88,7 @@ export async function deleteUser(userId: string, confirmRelinquish = false, dele

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

await ModerationReports.hideMessageReportsByUserId(
userId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Messages, Rooms, Subscriptions, ReadReceipts, Team } from '@rocket.chat/models';
import { Messages, Rooms, Subscriptions, ReadReceipts, ReadReceiptsArchive, Team } from '@rocket.chat/models';

import type { SubscribedRoomsForUserWithDetails } from './getRoomsWithSingleOwner';
import { addUserRolesAsync } from '../../../../server/lib/roles/addUserRoles';
Expand Down Expand Up @@ -36,14 +36,15 @@ const bulkRoomCleanUp = async (rids: string[]) => {
// no bulk deletion for files
await Promise.all(rids.map((rid) => FileUpload.removeFilesByRoomId(rid)));

const [, , , deletedRoomIds] = await Promise.all([
const [, , , , deletedRoomIds] = await Promise.all([
Subscriptions.removeByRoomIds(rids, {
async onTrash(doc) {
void notifyOnSubscriptionChanged(doc, 'removed');
},
}),
Messages.removeByRoomIds(rids),
ReadReceipts.removeByRoomIds(rids),
ReadReceiptsArchive.removeByRoomIds(rids),
bulkTeamCleanup(rids),
]);

Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/app/livechat/server/lib/guests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
LivechatRooms,
Messages,
ReadReceipts,
ReadReceiptsArchive,
Subscriptions,
LivechatContacts,
Users,
Expand Down Expand Up @@ -120,6 +121,7 @@ async function cleanGuestHistory(_id: string) {
FileUpload.removeFilesByRoomId(room._id),
Messages.removeByRoomId(room._id),
ReadReceipts.removeByRoomId(room._id),
ReadReceiptsArchive.removeByRoomId(room._id),
]);
}

Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/app/livechat/server/lib/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
Subscriptions,
Users,
ReadReceipts,
ReadReceiptsArchive,
} from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';

Expand Down Expand Up @@ -286,6 +287,7 @@ export async function removeOmnichannelRoom(rid: string) {
const result = await Promise.allSettled([
Messages.removeByRoomId(rid),
ReadReceipts.removeByRoomId(rid),
ReadReceiptsArchive.removeByRoomId(rid),
Subscriptions.removeByRoomId(rid, {
async onTrash(doc) {
void notifyOnSubscriptionChanged(doc, 'removed');
Expand Down
8 changes: 1 addition & 7 deletions apps/meteor/app/message-pin/server/pinMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Message } from '@rocket.chat/core-services';
import { isQuoteAttachment, isRegisterUser } from '@rocket.chat/core-typings';
import type { IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms, Subscriptions, Users, ReadReceipts } from '@rocket.chat/models';
import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { isTruthy } from '@rocket.chat/tools';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
Expand Down Expand Up @@ -91,9 +91,6 @@ export async function pinMessage(message: IMessage, userId: string, pinnedAt?: D
originalMessage = await Message.beforeSave({ message: originalMessage, room, user: me });

await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned);
if (settings.get('Message_Read_Receipt_Store_Users')) {
await ReadReceipts.setPinnedByMessageId(originalMessage._id, originalMessage.pinned);
}
if (isTheLastMessage(room, originalMessage)) {
await Rooms.setLastMessagePinned(room._id, originalMessage.pinnedBy, originalMessage.pinned);
}
Expand Down Expand Up @@ -192,9 +189,6 @@ export const unpinMessage = async (userId: string, message: IMessage) => {
await Apps.self?.triggerEvent(AppEvents.IPostMessagePinned, originalMessage, me, originalMessage.pinned);

await Messages.setPinnedByIdAndUserId(originalMessage._id, originalMessage.pinnedBy, originalMessage.pinned);
if (settings.get('Message_Read_Receipt_Store_Users')) {
await ReadReceipts.setPinnedByMessageId(originalMessage._id, originalMessage.pinned);
}
void notifyOnMessageChange({
id: message._id,
});
Expand Down
8 changes: 1 addition & 7 deletions apps/meteor/app/slackbridge/server/SlackAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import https from 'https';
import url from 'url';

import { Message } from '@rocket.chat/core-services';
import { Messages, Rooms, Users, ReadReceipts } from '@rocket.chat/models';
import { Messages, Rooms, Users } from '@rocket.chat/models';
import { App as SlackApp } from '@slack/bolt';
import { RTMClient } from '@slack/rtm-api';
import { Meteor } from 'meteor/meteor';
Expand Down Expand Up @@ -1201,9 +1201,6 @@ export default class SlackAdapter {
if (!isImporting && slackMessage.attachments[0].channel_id && slackMessage.attachments[0].ts) {
const messageId = this.createSlackMessageId(slackMessage.attachments[0].ts, slackMessage.attachments[0].channel_id);
await Messages.setPinnedByIdAndUserId(messageId, rocketMsgObj.u, true, new Date(parseInt(slackMessage.ts.split('.')[0]) * 1000));
if (settings.get('Message_Read_Receipt_Store_Users')) {
await ReadReceipts.setPinnedByMessageId(messageId, true);
}
}

return rocketMsgObj;
Expand Down Expand Up @@ -1412,9 +1409,6 @@ export default class SlackAdapter {

const messageId = this.createSlackMessageId(pin.message.ts, pin.channel);
await Messages.setPinnedByIdAndUserId(messageId, msgObj.u, true, new Date(parseInt(pin.message.ts.split('.')[0]) * 1000));
if (settings.get('Message_Read_Receipt_Store_Users')) {
await ReadReceipts.setPinnedByMessageId(messageId, true);
}
}
}
}
Expand Down
7 changes: 2 additions & 5 deletions apps/meteor/app/threads/server/functions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { Messages, Subscriptions, ReadReceipts, NotificationQueue } from '@rocket.chat/models';
import { Messages, Subscriptions, NotificationQueue } from '@rocket.chat/models';

import {
notifyOnSubscriptionChangedByRoomIdAndUserIds,
Expand Down Expand Up @@ -40,10 +40,7 @@ export async function reply({ tmid }: { tmid?: string }, message: IMessage, pare
// Notify message mentioned users and highlights
const mentionedUsers = [...new Set([...mentionIds, ...highlightsUids])];

const promises = [
ReadReceipts.setAsThreadById(tmid),
Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, threadFollowersUids, tmid, notifyOptions),
];
const promises = [Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, threadFollowersUids, tmid, notifyOptions)];

if (mentionedUsers.length) {
promises.push(Subscriptions.addUnreadThreadByRoomIdAndUserIds(rid, mentionedUsers, tmid, { userMention: true }));
Expand Down
13 changes: 1 addition & 12 deletions apps/meteor/definition/IRoomTypeConfig.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,4 @@
import type {
IRoom,
RoomType,
IUser,
IMessage,
IReadReceipt,
ValueOf,
AtLeast,
ISubscription,
IOmnichannelRoom,
} from '@rocket.chat/core-typings';
import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast, ISubscription, IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { Keys as IconName } from '@rocket.chat/icons';
import type { IRouterPaths, RouteName } from '@rocket.chat/ui-contexts';

Expand Down Expand Up @@ -106,7 +96,6 @@ export interface IRoomTypeServerDirectives {
) => Promise<{ title: string | undefined; text: string; name: string | undefined }>;
getMsgSender: (message: IMessage) => Promise<IUser | null>;
includeInRoomSearch: () => boolean;
getReadReceiptsExtraData: (message: IMessage) => Partial<IReadReceipt>;
includeInDashboard: () => boolean;
roomFind?: (rid: string) => Promise<IRoom | undefined> | Promise<IOmnichannelRoom | null> | IRoom | undefined;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ReadReceipts } from '@rocket.chat/models';
import { ReadReceipts, ReadReceiptsArchive } from '@rocket.chat/models';

import { callbacks } from '../../../../../server/lib/callbacks';

callbacks.add(
'afterDeleteRoom',
async (rid) => {
await ReadReceipts.removeByRoomId(rid);
await ReadReceiptsArchive.removeByRoomId(rid);
return rid;
},
callbacks.priority.LOW,
Expand Down
Loading
Loading