From 4d57df197f62e0f34ce90c26167739a8e324d482 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:21:42 +0000 Subject: [PATCH 01/20] Initial plan From e261e131e42fbae26c20e8edd39664de6a659d79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:25:57 +0000 Subject: [PATCH 02/20] Implement cold storage for read receipts Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../ee/server/cron/readReceiptsArchive.ts | 54 ++++++++++++++++++ .../lib/message-read-receipt/ReadReceipt.ts | 15 ++++- .../ee/server/methods/getReadReceipts.ts | 2 +- .../ee/server/models/ReadReceiptsArchive.ts | 6 ++ .../ee/server/models/raw/ReadReceipts.ts | 4 ++ .../server/models/raw/ReadReceiptsArchive.ts | 57 +++++++++++++++++++ apps/meteor/ee/server/models/startup.ts | 1 + apps/meteor/ee/server/startup/index.ts | 1 + .../ee/server/startup/readReceiptsArchive.ts | 3 + .../core-typings/src/IMessage/IMessage.ts | 3 + .../src/models/IReadReceiptsModel.ts | 1 + packages/models/src/index.ts | 1 + 12 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 apps/meteor/ee/server/cron/readReceiptsArchive.ts create mode 100644 apps/meteor/ee/server/models/ReadReceiptsArchive.ts create mode 100644 apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts create mode 100644 apps/meteor/ee/server/startup/readReceiptsArchive.ts diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.ts new file mode 100644 index 0000000000000..35c4139764eb7 --- /dev/null +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -0,0 +1,54 @@ +import { cronJobs } from '@rocket.chat/cron'; +import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; +import { Logger } from '@rocket.chat/logger'; + +const logger = new Logger('ReadReceiptsArchive'); + +// 30 days in milliseconds +const RETENTION_DAYS = 30; + +async function archiveOldReadReceipts(): Promise { + const cutoffDate = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000); + + logger.info(`Starting to archive read receipts older than ${cutoffDate.toISOString()}`); + + // Find all receipts older than 30 days + const oldReceipts = await ReadReceipts.findOlderThan(cutoffDate).toArray(); + + if (oldReceipts.length === 0) { + logger.info('No read receipts to archive'); + return; + } + + logger.info(`Found ${oldReceipts.length} read receipts to archive`); + + // Get unique message IDs from the receipts to be archived + const messageIds = [...new Set(oldReceipts.map((receipt) => receipt.messageId))]; + + try { + // Insert receipts into archive collection + if (oldReceipts.length > 0) { + await ReadReceiptsArchive.insertMany(oldReceipts); + logger.info(`Successfully archived ${oldReceipts.length} read receipts`); + } + + // Mark messages as having archived receipts + const updateResult = await Messages.updateMany( + { _id: { $in: messageIds } }, + { $set: { receiptsArchived: true } } + ); + logger.info(`Marked ${updateResult.modifiedCount} messages as having archived receipts`); + + // Delete old receipts from hot storage + const deleteResult = await ReadReceipts.deleteMany({ ts: { $lt: cutoffDate } }); + logger.info(`Deleted ${deleteResult.deletedCount} old receipts from hot storage`); + } catch (error) { + logger.error('Error during read receipts archiving:', error); + throw error; + } +} + +export async function readReceiptsArchiveCron(): Promise { + // Run daily at 2 AM + return cronJobs.add('ReadReceiptsArchive', '0 2 * * *', async () => archiveOldReadReceipts()); +} diff --git a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts index 8be6bc78ab1b8..7d2db5dda14f6 100644 --- a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts +++ b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts @@ -1,6 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { IMessage, IRoom, IReadReceipt, IReadReceiptWithUser } from '@rocket.chat/core-typings'; -import { LivechatVisitors, ReadReceipts, Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { LivechatVisitors, ReadReceipts, ReadReceiptsArchive, Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../../../app/lib/server/lib/notifyListener'; @@ -147,8 +147,17 @@ class ReadReceiptClass { } } - async getReceipts(message: Pick): Promise { - const receipts = await ReadReceipts.findByMessageId(message._id).toArray(); + async getReceipts(message: Pick): Promise { + // Query hot storage (always) + const hotReceipts = await ReadReceipts.findByMessageId(message._id).toArray(); + + // Query cold storage only if message has archived receipts + const coldReceipts = message.receiptsArchived + ? await ReadReceiptsArchive.findByMessageId(message._id).toArray() + : []; + + // Combine receipts from both storages + const receipts = [...hotReceipts, ...coldReceipts]; return Promise.all( receipts.map(async (receipt) => ({ diff --git a/apps/meteor/ee/server/methods/getReadReceipts.ts b/apps/meteor/ee/server/methods/getReadReceipts.ts index ee323dc4f4837..02923a8f71986 100644 --- a/apps/meteor/ee/server/methods/getReadReceipts.ts +++ b/apps/meteor/ee/server/methods/getReadReceipts.ts @@ -21,7 +21,7 @@ export const getReadReceiptsFunction = async function (messageId: IMessage['_id' } check(messageId, String); - const message = await Messages.findOneById(messageId); + const message = await Messages.findOneById(messageId, { projection: { _id: 1, rid: 1, receiptsArchived: 1 } }); if (!message) { throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'getReadReceipts', diff --git a/apps/meteor/ee/server/models/ReadReceiptsArchive.ts b/apps/meteor/ee/server/models/ReadReceiptsArchive.ts new file mode 100644 index 0000000000000..34570446561a6 --- /dev/null +++ b/apps/meteor/ee/server/models/ReadReceiptsArchive.ts @@ -0,0 +1,6 @@ +import { registerModel } from '@rocket.chat/models'; + +import { ReadReceiptsArchiveRaw } from './raw/ReadReceiptsArchive'; +import { db } from '../../../server/database/utils'; + +registerModel('IReadReceiptsArchiveModel', new ReadReceiptsArchiveRaw(db)); diff --git a/apps/meteor/ee/server/models/raw/ReadReceipts.ts b/apps/meteor/ee/server/models/raw/ReadReceipts.ts index c0fd7e890904a..31ee523243b4a 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceipts.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceipts.ts @@ -75,4 +75,8 @@ export class ReadReceiptsRaw extends BaseRaw implements IReadRecei setAsThreadById(messageId: string): Promise { return this.updateMany({ messageId }, { $set: { tmid: messageId } }); } + + findOlderThan(date: Date): FindCursor { + return this.find({ ts: { $lt: date } }); + } } diff --git a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts new file mode 100644 index 0000000000000..57892fcc599de --- /dev/null +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -0,0 +1,57 @@ +import type { IReadReceipt, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IReadReceiptsModel } from '@rocket.chat/model-typings'; +import { BaseRaw } from '@rocket.chat/models'; +import type { Collection, FindCursor, Db, IndexDescription } from 'mongodb'; + +export class ReadReceiptsArchiveRaw extends BaseRaw implements IReadReceiptsModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'read_receipts_archive', trash); + } + + protected override modelIndexes(): IndexDescription[] { + return [{ key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, { key: { messageId: 1 } }, { key: { userId: 1 } }, { key: { ts: 1 } }]; + } + + findByMessageId(messageId: string): FindCursor { + return this.find({ messageId }); + } + + // Archive doesn't need all the delete methods from hot storage + // But we implement them to satisfy the interface + async removeByUserId(userId: string) { + return this.deleteMany({ userId }); + } + + async removeByRoomId(roomId: string) { + return this.deleteMany({ roomId }); + } + + async removeByRoomIds(roomIds: string[]) { + return this.deleteMany({ roomId: { $in: roomIds } }); + } + + async removeByMessageId(messageId: string) { + return this.deleteMany({ messageId }); + } + + async removeByMessageIds(messageIds: string[]) { + return this.deleteMany({ messageId: { $in: messageIds } }); + } + + async removeByIdPinnedTimestampLimitAndUsers() { + // Not needed for archive, but required by interface + return { acknowledged: true, deletedCount: 0 }; + } + + async setPinnedByMessageId(messageId: string, pinned = true) { + return this.updateMany({ messageId }, { $set: { pinned } }); + } + + async setAsThreadById(messageId: string) { + return this.updateMany({ messageId }, { $set: { tmid: messageId } }); + } + + findOlderThan(date: Date): FindCursor { + return this.find({ ts: { $lt: date } }); + } +} diff --git a/apps/meteor/ee/server/models/startup.ts b/apps/meteor/ee/server/models/startup.ts index b558c95b4639f..a3e1c6175f5cc 100644 --- a/apps/meteor/ee/server/models/startup.ts +++ b/apps/meteor/ee/server/models/startup.ts @@ -6,6 +6,7 @@ import { License } from '@rocket.chat/license'; import('./OmnichannelServiceLevelAgreements'); import('./AuditLog'); import('./ReadReceipts'); +import('./ReadReceiptsArchive'); void License.onLicense('livechat-enterprise', () => { import('./CannedResponse'); diff --git a/apps/meteor/ee/server/startup/index.ts b/apps/meteor/ee/server/startup/index.ts index e70c88305f354..8aaadc847ffe8 100644 --- a/apps/meteor/ee/server/startup/index.ts +++ b/apps/meteor/ee/server/startup/index.ts @@ -5,6 +5,7 @@ import './engagementDashboard'; import './maxRoomsPerGuest'; import './upsell'; import './services'; +import './readReceiptsArchive'; import { api } from '@rocket.chat/core-services'; import { isRunningMs } from '../../../server/lib/isRunningMs'; diff --git a/apps/meteor/ee/server/startup/readReceiptsArchive.ts b/apps/meteor/ee/server/startup/readReceiptsArchive.ts new file mode 100644 index 0000000000000..f8fcd3c8be07f --- /dev/null +++ b/apps/meteor/ee/server/startup/readReceiptsArchive.ts @@ -0,0 +1,3 @@ +import { readReceiptsArchiveCron } from '../cron/readReceiptsArchive'; + +void readReceiptsArchiveCron(); diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 9f61d6fad1b56..06749da74acfc 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -237,6 +237,9 @@ export interface IMessage extends IRocketChatRecord { customFields?: Record; content?: EncryptedContent; + + // Read receipts migration flag + receiptsArchived?: boolean; } export type EncryptedMessageContent = Required>; diff --git a/packages/model-typings/src/models/IReadReceiptsModel.ts b/packages/model-typings/src/models/IReadReceiptsModel.ts index fb097619f3d7c..7878047e7ded3 100644 --- a/packages/model-typings/src/models/IReadReceiptsModel.ts +++ b/packages/model-typings/src/models/IReadReceiptsModel.ts @@ -20,4 +20,5 @@ export interface IReadReceiptsModel extends IBaseModel { ): Promise; setPinnedByMessageId(messageId: string, pinned?: boolean): Promise; setAsThreadById(messageId: string): Promise; + findOlderThan(date: Date): FindCursor; } diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 9ae91adf616bc..beeb733c5cc23 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -177,6 +177,7 @@ export const OEmbedCache = proxify('IOEmbedCacheModel'); export const PushToken = proxify('IPushTokenModel'); export const Permissions = proxify('IPermissionsModel'); export const ReadReceipts = proxify('IReadReceiptsModel'); +export const ReadReceiptsArchive = proxify('IReadReceiptsArchiveModel'); export const MessageReads = proxify('IMessageReadsModel'); export const Reports = proxify('IReportsModel'); export const Roles = proxify('IRolesModel'); From 220b6296279cacbad1854c10d483ea89d169ef02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:29:05 +0000 Subject: [PATCH 03/20] Update all deletion points to also delete from archive collection Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- apps/meteor/app/lib/server/functions/cleanRoomHistory.ts | 4 +++- apps/meteor/app/lib/server/functions/deleteMessage.ts | 3 ++- apps/meteor/app/lib/server/functions/deleteUser.ts | 2 ++ .../app/lib/server/functions/relinquishRoomOwnerships.ts | 5 +++-- apps/meteor/app/livechat/server/lib/guests.ts | 2 ++ apps/meteor/app/livechat/server/lib/rooms.ts | 2 ++ .../app/message-read-receipt/server/hooks/afterDeleteRoom.ts | 3 ++- apps/meteor/server/lib/moderation/deleteReportedMessages.ts | 3 ++- packages/models/src/dummy/ReadReceipts.ts | 4 ++++ 9 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts index 2bea0914ee00f..4b5e43857243f 100644 --- a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts +++ b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts @@ -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, Users } from '@rocket.chat/models'; import { deleteRoom } from './deleteRoom'; import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../../lib/constants'; @@ -148,8 +148,10 @@ export async function cleanRoomHistory({ .map((user) => user._id) .toArray(); await ReadReceipts.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, uids, ignoreThreads); + await ReadReceiptsArchive.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, uids, ignoreThreads); } else if (selectedMessageIds) { await ReadReceipts.removeByMessageIds(selectedMessageIds); + await ReadReceiptsArchive.removeByMessageIds(selectedMessageIds); } if (count) { diff --git a/apps/meteor/app/lib/server/functions/deleteMessage.ts b/apps/meteor/app/lib/server/functions/deleteMessage.ts index 24176cb529dd8..da3cb2e37eef8 100644 --- a/apps/meteor/app/lib/server/functions/deleteMessage.ts +++ b/apps/meteor/app/lib/server/functions/deleteMessage.ts @@ -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'; @@ -69,6 +69,7 @@ export async function deleteMessage(message: IMessage, user: IUser): Promise { // 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'); @@ -44,6 +44,7 @@ const bulkRoomCleanUp = async (rids: string[]) => { }), Messages.removeByRoomIds(rids), ReadReceipts.removeByRoomIds(rids), + ReadReceiptsArchive.removeByRoomIds(rids), bulkTeamCleanup(rids), ]); diff --git a/apps/meteor/app/livechat/server/lib/guests.ts b/apps/meteor/app/livechat/server/lib/guests.ts index f913ef83c05d6..228008e2d18b7 100644 --- a/apps/meteor/app/livechat/server/lib/guests.ts +++ b/apps/meteor/app/livechat/server/lib/guests.ts @@ -7,6 +7,7 @@ import { LivechatRooms, Messages, ReadReceipts, + ReadReceiptsArchive, Subscriptions, LivechatContacts, Users, @@ -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), ]); } diff --git a/apps/meteor/app/livechat/server/lib/rooms.ts b/apps/meteor/app/livechat/server/lib/rooms.ts index 03dfbb1dd704a..ad365262e3a40 100644 --- a/apps/meteor/app/livechat/server/lib/rooms.ts +++ b/apps/meteor/app/livechat/server/lib/rooms.ts @@ -20,6 +20,7 @@ import { Subscriptions, Users, ReadReceipts, + ReadReceiptsArchive, } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -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'); diff --git a/apps/meteor/ee/app/message-read-receipt/server/hooks/afterDeleteRoom.ts b/apps/meteor/ee/app/message-read-receipt/server/hooks/afterDeleteRoom.ts index 09401e2d53797..e0f3af49b2d69 100644 --- a/apps/meteor/ee/app/message-read-receipt/server/hooks/afterDeleteRoom.ts +++ b/apps/meteor/ee/app/message-read-receipt/server/hooks/afterDeleteRoom.ts @@ -1,4 +1,4 @@ -import { ReadReceipts } from '@rocket.chat/models'; +import { ReadReceipts, ReadReceiptsArchive } from '@rocket.chat/models'; import { callbacks } from '../../../../../server/lib/callbacks'; @@ -6,6 +6,7 @@ callbacks.add( 'afterDeleteRoom', async (rid) => { await ReadReceipts.removeByRoomId(rid); + await ReadReceiptsArchive.removeByRoomId(rid); return rid; }, callbacks.priority.LOW, diff --git a/apps/meteor/server/lib/moderation/deleteReportedMessages.ts b/apps/meteor/server/lib/moderation/deleteReportedMessages.ts index 204ae90d8c774..c835c2f9bfe9b 100644 --- a/apps/meteor/server/lib/moderation/deleteReportedMessages.ts +++ b/apps/meteor/server/lib/moderation/deleteReportedMessages.ts @@ -1,6 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { IUser, IMessage } from '@rocket.chat/core-typings'; -import { Messages, Uploads, ReadReceipts } from '@rocket.chat/models'; +import { Messages, Uploads, ReadReceipts, ReadReceiptsArchive } from '@rocket.chat/models'; import { FileUpload } from '../../../app/file-upload/server'; import { settings } from '../../../app/settings/server'; @@ -41,6 +41,7 @@ export async function deleteReportedMessages(messages: IMessage[], user: IUser): await Messages.deleteMany({ _id: { $in: messageIds } }); } await ReadReceipts.removeByMessageIds(messageIds); + await ReadReceiptsArchive.removeByMessageIds(messageIds); const store = FileUpload.getStore('Uploads'); await Promise.all(files.map((file) => store.deleteById(file))); diff --git a/packages/models/src/dummy/ReadReceipts.ts b/packages/models/src/dummy/ReadReceipts.ts index 3d644d527a058..aa3a4311e29e0 100644 --- a/packages/models/src/dummy/ReadReceipts.ts +++ b/packages/models/src/dummy/ReadReceipts.ts @@ -51,4 +51,8 @@ export class ReadReceiptsDummy extends BaseDummy implements IReadR setAsThreadById(_messageId: string): Promise { return this.updateMany({}, {}); } + + findOlderThan(_date: Date): FindCursor { + return this.find({}); + } } From 2baea078fdafd19c6a40ec59c93115915ceefa7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:30:34 +0000 Subject: [PATCH 04/20] Address code review feedback: improve type safety and readability Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../ee/server/cron/readReceiptsArchive.ts | 25 ++++++++++++++++--- .../lib/message-read-receipt/ReadReceipt.ts | 7 +++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.ts index 35c4139764eb7..e8157d3ea0d2d 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -6,9 +6,10 @@ const logger = new Logger('ReadReceiptsArchive'); // 30 days in milliseconds const RETENTION_DAYS = 30; +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; async function archiveOldReadReceipts(): Promise { - const cutoffDate = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000); + const cutoffDate = new Date(Date.now() - RETENTION_DAYS * MILLISECONDS_PER_DAY); logger.info(`Starting to archive read receipts older than ${cutoffDate.toISOString()}`); @@ -26,10 +27,26 @@ async function archiveOldReadReceipts(): Promise { const messageIds = [...new Set(oldReceipts.map((receipt) => receipt.messageId))]; try { - // Insert receipts into archive collection + // Insert receipts into archive collection (using insertMany with ordered: false to continue on duplicate key errors) if (oldReceipts.length > 0) { - await ReadReceiptsArchive.insertMany(oldReceipts); - logger.info(`Successfully archived ${oldReceipts.length} read receipts`); + try { + await ReadReceiptsArchive.insertMany(oldReceipts, { ordered: false }); + logger.info(`Successfully archived ${oldReceipts.length} read receipts`); + } catch (error: unknown) { + // If we get duplicate key errors, some receipts were already archived, which is fine + // We'll continue to mark messages and delete from hot storage + if (error && typeof error === 'object' && ('code' in error || 'name' in error)) { + const mongoError = error as { code?: number; name?: string; result?: { insertedCount?: number } }; + if (mongoError.code === 11000 || mongoError.name === 'MongoBulkWriteError') { + const insertedCount = mongoError.result?.insertedCount || 0; + logger.info(`Archived ${insertedCount} read receipts (some were already archived)`); + } else { + throw error; + } + } else { + throw error; + } + } } // Mark messages as having archived receipts diff --git a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts index 7d2db5dda14f6..f64987a4ff781 100644 --- a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts +++ b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts @@ -152,9 +152,10 @@ class ReadReceiptClass { const hotReceipts = await ReadReceipts.findByMessageId(message._id).toArray(); // Query cold storage only if message has archived receipts - const coldReceipts = message.receiptsArchived - ? await ReadReceiptsArchive.findByMessageId(message._id).toArray() - : []; + let coldReceipts: IReadReceipt[] = []; + if (message.receiptsArchived) { + coldReceipts = await ReadReceiptsArchive.findByMessageId(message._id).toArray(); + } // Combine receipts from both storages const receipts = [...hotReceipts, ...coldReceipts]; From 6d6f82f347fc443bde11b4fa4545a2c7a81bde89 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:38:35 +0000 Subject: [PATCH 05/20] Add configurable settings and tests for read receipts archiving Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../server/cron/readReceiptsArchive.spec.ts | 153 ++++++++++++++++++ .../ee/server/cron/readReceiptsArchive.ts | 26 ++- apps/meteor/server/settings/message.ts | 16 ++ 3 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts new file mode 100644 index 0000000000000..a2bf2f2c49f50 --- /dev/null +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts @@ -0,0 +1,153 @@ +import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; + +import { archiveOldReadReceipts } from './readReceiptsArchive'; + +jest.mock('@rocket.chat/models', () => ({ + ReadReceipts: { + findOlderThan: jest.fn(), + deleteMany: jest.fn(), + }, + ReadReceiptsArchive: { + insertMany: jest.fn(), + }, + Messages: { + updateMany: jest.fn(), + }, +})); + +jest.mock('@rocket.chat/logger', () => ({ + Logger: jest.fn().mockImplementation(() => ({ + info: jest.fn(), + error: jest.fn(), + })), +})); + +jest.mock('../../../app/settings/server', () => ({ + settings: { + get: jest.fn(), + watch: jest.fn(), + }, +})); + +const { settings } = require('../../../app/settings/server'); + +describe('Read Receipts Archive', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should use default retention days when setting is not available', async () => { + (settings.get as jest.Mock).mockReturnValue(undefined); + + const toArrayMock = jest.fn().mockResolvedValue([]); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + + await archiveOldReadReceipts(); + + expect(ReadReceipts.findOlderThan).toHaveBeenCalled(); + const cutoffDate = (ReadReceipts.findOlderThan as jest.Mock).mock.calls[0][0]; + const daysDiff = Math.floor((Date.now() - cutoffDate.getTime()) / (24 * 60 * 60 * 1000)); + expect(daysDiff).toBe(30); // Default 30 days + }); + + it('should use configured retention days', async () => { + (settings.get as jest.Mock).mockReturnValue(45); + + const toArrayMock = jest.fn().mockResolvedValue([]); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + + await archiveOldReadReceipts(); + + expect(ReadReceipts.findOlderThan).toHaveBeenCalled(); + const cutoffDate = (ReadReceipts.findOlderThan as jest.Mock).mock.calls[0][0]; + const daysDiff = Math.floor((Date.now() - cutoffDate.getTime()) / (24 * 60 * 60 * 1000)); + expect(daysDiff).toBe(45); + }); + + it('should not process when no old receipts found', async () => { + (settings.get as jest.Mock).mockReturnValue(30); + + const toArrayMock = jest.fn().mockResolvedValue([]); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + + await archiveOldReadReceipts(); + + expect(ReadReceiptsArchive.insertMany).not.toHaveBeenCalled(); + expect(Messages.updateMany).not.toHaveBeenCalled(); + expect(ReadReceipts.deleteMany).not.toHaveBeenCalled(); + }); + + it('should archive old receipts and mark messages', async () => { + (settings.get as jest.Mock).mockReturnValue(30); + + const oldReceipts = [ + { _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }, + { _id: '2', messageId: 'msg2', userId: 'user2', ts: new Date('2020-01-02') }, + { _id: '3', messageId: 'msg1', userId: 'user3', ts: new Date('2020-01-03') }, + ]; + + const toArrayMock = jest.fn().mockResolvedValue(oldReceipts); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + (ReadReceiptsArchive.insertMany as jest.Mock).mockResolvedValue({ insertedCount: 3 }); + (Messages.updateMany as jest.Mock).mockResolvedValue({ modifiedCount: 2 }); + (ReadReceipts.deleteMany as jest.Mock).mockResolvedValue({ deletedCount: 3 }); + + await archiveOldReadReceipts(); + + // Verify insertMany was called with receipts + expect(ReadReceiptsArchive.insertMany).toHaveBeenCalledWith(oldReceipts, { ordered: false }); + + // Verify messages were marked + expect(Messages.updateMany).toHaveBeenCalledWith( + { _id: { $in: ['msg1', 'msg2'] } }, + { $set: { receiptsArchived: true } } + ); + + // Verify old receipts were deleted + expect(ReadReceipts.deleteMany).toHaveBeenCalledWith(expect.objectContaining({ ts: expect.any(Object) })); + }); + + it('should handle duplicate key errors gracefully', async () => { + (settings.get as jest.Mock).mockReturnValue(30); + + const oldReceipts = [ + { _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }, + ]; + + const toArrayMock = jest.fn().mockResolvedValue(oldReceipts); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + + // Simulate duplicate key error + const duplicateError = Object.assign(new Error('Duplicate key'), { + code: 11000, + result: { insertedCount: 0 }, + }); + (ReadReceiptsArchive.insertMany as jest.Mock).mockRejectedValue(duplicateError); + (Messages.updateMany as jest.Mock).mockResolvedValue({ modifiedCount: 1 }); + (ReadReceipts.deleteMany as jest.Mock).mockResolvedValue({ deletedCount: 1 }); + + await archiveOldReadReceipts(); + + // Should continue despite duplicate error + expect(Messages.updateMany).toHaveBeenCalled(); + expect(ReadReceipts.deleteMany).toHaveBeenCalled(); + }); + + it('should rethrow non-duplicate errors', async () => { + (settings.get as jest.Mock).mockReturnValue(30); + + const oldReceipts = [ + { _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }, + ]; + + const toArrayMock = jest.fn().mockResolvedValue(oldReceipts); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + + // Simulate other error + const otherError = new Error('Connection failed'); + (ReadReceiptsArchive.insertMany as jest.Mock).mockRejectedValue(otherError); + + await expect(archiveOldReadReceipts()).rejects.toThrow('Connection failed'); + }); +}); + diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.ts index e8157d3ea0d2d..4d2d15095b038 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -2,14 +2,15 @@ import { cronJobs } from '@rocket.chat/cron'; import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; import { Logger } from '@rocket.chat/logger'; +import { settings } from '../../../app/settings/server'; + const logger = new Logger('ReadReceiptsArchive'); -// 30 days in milliseconds -const RETENTION_DAYS = 30; const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; -async function archiveOldReadReceipts(): Promise { - const cutoffDate = new Date(Date.now() - RETENTION_DAYS * MILLISECONDS_PER_DAY); +export async function archiveOldReadReceipts(): Promise { + const retentionDays = settings.get('Message_Read_Receipt_Archive_Retention_Days') || 30; + const cutoffDate = new Date(Date.now() - retentionDays * MILLISECONDS_PER_DAY); logger.info(`Starting to archive read receipts older than ${cutoffDate.toISOString()}`); @@ -66,6 +67,19 @@ async function archiveOldReadReceipts(): Promise { } export async function readReceiptsArchiveCron(): Promise { - // Run daily at 2 AM - return cronJobs.add('ReadReceiptsArchive', '0 2 * * *', async () => archiveOldReadReceipts()); + const cronSchedule = settings.get('Message_Read_Receipt_Archive_Cron') || '0 2 * * *'; + + // Remove existing job if it exists + if (await cronJobs.has('ReadReceiptsArchive')) { + await cronJobs.remove('ReadReceiptsArchive'); + } + + return cronJobs.add('ReadReceiptsArchive', cronSchedule, async () => archiveOldReadReceipts()); } + +// Watch for settings changes and update the cron schedule +settings.watch('Message_Read_Receipt_Archive_Cron', async (value) => { + if (value) { + await readReceiptsArchiveCron(); + } +}); diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts index 85a829a6f06e1..dc4a210b56a8f 100644 --- a/apps/meteor/server/settings/message.ts +++ b/apps/meteor/server/settings/message.ts @@ -65,6 +65,22 @@ export const createMessageSettings = () => public: true, enableQuery: { _id: 'Message_Read_Receipt_Enabled', value: true }, }); + await this.add('Message_Read_Receipt_Archive_Retention_Days', 30, { + type: 'int', + enterprise: true, + invalidValue: 30, + modules: ['message-read-receipt'], + i18nDescription: 'Message_Read_Receipt_Archive_Retention_Days_Description', + enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, + }); + await this.add('Message_Read_Receipt_Archive_Cron', '0 2 * * *', { + type: 'string', + enterprise: true, + invalidValue: '0 2 * * *', + modules: ['message-read-receipt'], + i18nDescription: 'Message_Read_Receipt_Archive_Cron_Description', + enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, + }); }); await this.add('Message_CustomDomain_AutoLink', '', { type: 'string', From b138dca13ddbd39acad58ac08b880d6fefbed93f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:40:13 +0000 Subject: [PATCH 06/20] Add EE cron tests to jest config and update test configuration Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../ee/server/cron/readReceiptsArchive.spec.ts | 13 ++++++++++--- apps/meteor/jest.config.ts | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts index a2bf2f2c49f50..739caeea398ce 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts @@ -1,5 +1,3 @@ -import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; - import { archiveOldReadReceipts } from './readReceiptsArchive'; jest.mock('@rocket.chat/models', () => ({ @@ -29,7 +27,16 @@ jest.mock('../../../app/settings/server', () => ({ }, })); -const { settings } = require('../../../app/settings/server'); +jest.mock('@rocket.chat/cron', () => ({ + cronJobs: { + add: jest.fn(), + has: jest.fn(), + remove: jest.fn(), + }, +})); + +import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; +import { settings } from '../../../app/settings/server'; describe('Read Receipts Archive', () => { beforeEach(() => { diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 988ae37c8499d..4599076e096f1 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -39,6 +39,7 @@ export default { '/ee/app/authorization/server/validateUserRoles.spec.ts', '/ee/app/license/server/**/*.spec.ts', '/ee/server/patches/**/*.spec.ts', + '/ee/server/cron/**/*.spec.ts', '/app/cloud/server/functions/supportedVersionsToken/**.spec.ts', '/app/utils/lib/**.spec.ts', '/server/lib/auditServerEvents/**.spec.ts', From e89a1059c6f48078b6f09cb2da3231149dc11e8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:41:28 +0000 Subject: [PATCH 07/20] Move settings watcher to startup to ensure proper initialization Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- apps/meteor/ee/server/cron/readReceiptsArchive.ts | 7 ------- apps/meteor/ee/server/startup/readReceiptsArchive.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.ts index 4d2d15095b038..237544488f94a 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -76,10 +76,3 @@ export async function readReceiptsArchiveCron(): Promise { return cronJobs.add('ReadReceiptsArchive', cronSchedule, async () => archiveOldReadReceipts()); } - -// Watch for settings changes and update the cron schedule -settings.watch('Message_Read_Receipt_Archive_Cron', async (value) => { - if (value) { - await readReceiptsArchiveCron(); - } -}); diff --git a/apps/meteor/ee/server/startup/readReceiptsArchive.ts b/apps/meteor/ee/server/startup/readReceiptsArchive.ts index f8fcd3c8be07f..514dda721ec94 100644 --- a/apps/meteor/ee/server/startup/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/startup/readReceiptsArchive.ts @@ -1,3 +1,12 @@ +import { settings } from '../../../app/settings/server'; import { readReceiptsArchiveCron } from '../cron/readReceiptsArchive'; +// Initialize the cron job void readReceiptsArchiveCron(); + +// Watch for settings changes and update the cron schedule +settings.watch('Message_Read_Receipt_Archive_Cron', async (value) => { + if (value) { + await readReceiptsArchiveCron(); + } +}); From 4da26637d7ff77efd8ec7c62e746566d3e966485 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:43:52 +0000 Subject: [PATCH 08/20] Add English and Portuguese translations for new archive settings Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- packages/i18n/src/locales/en.i18n.json | 4 ++++ packages/i18n/src/locales/pt-BR.i18n.json | 4 ++++ packages/i18n/src/locales/pt.i18n.json | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c465511523212..274e6d1fce048 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3480,6 +3480,10 @@ "Message_Read_Receipt_Enabled": "Show Read Receipts", "Message_Read_Receipt_Store_Users": "Detailed Read Receipts", "Message_Read_Receipt_Store_Users_Description": "Shows each user's read receipts", + "Message_Read_Receipt_Archive_Retention_Days": "Archive Retention Days", + "Message_Read_Receipt_Archive_Retention_Days_Description": "Number of days to keep read receipts in hot storage before archiving to cold storage", + "Message_Read_Receipt_Archive_Cron": "Archive Cron Schedule", + "Message_Read_Receipt_Archive_Cron_Description": "Cron expression for the archiving schedule (e.g., '0 2 * * *' for daily at 2 AM)", "Message_ShowDeletedStatus": "Show Deleted Status", "Message_ShowEditedStatus": "Show Edited Status", "Message_ShowFormattingTips": "Show Formatting Tips", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index bb0fcd949a94c..e13c361e1bf78 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -3286,6 +3286,10 @@ "Message_Read_Receipt_Enabled": "Mostrar confirmação de leitura", "Message_Read_Receipt_Store_Users": "Confirmações de leitura detalhadas", "Message_Read_Receipt_Store_Users_Description": "Mostra os recibos de leitura de cada usuário", + "Message_Read_Receipt_Archive_Retention_Days": "Dias de Retenção no Arquivo", + "Message_Read_Receipt_Archive_Retention_Days_Description": "Número de dias para manter as confirmações de leitura no armazenamento ativo antes de arquivar no armazenamento frio", + "Message_Read_Receipt_Archive_Cron": "Agendamento do Arquivo", + "Message_Read_Receipt_Archive_Cron_Description": "Expressão cron para o agendamento do arquivamento (por exemplo, '0 2 * * *' para diariamente às 2h da manhã)", "Message_ShowDeletedStatus": "Mostrar status excluído", "Message_ShowEditedStatus": "Mostrar status editado", "Message_ShowFormattingTips": "Exibir dicas de formatação", diff --git a/packages/i18n/src/locales/pt.i18n.json b/packages/i18n/src/locales/pt.i18n.json index d77fa3919e2d3..21caeede9f39e 100644 --- a/packages/i18n/src/locales/pt.i18n.json +++ b/packages/i18n/src/locales/pt.i18n.json @@ -1701,6 +1701,10 @@ "Message_Read_Receipt_Enabled": "Mostrar Recibos de Leitura", "Message_Read_Receipt_Store_Users": "Recibos de leitura detalhados", "Message_Read_Receipt_Store_Users_Description": "Mostra os recibos de leitura de cada utilizador ", + "Message_Read_Receipt_Archive_Retention_Days": "Dias de Retenção no Arquivo", + "Message_Read_Receipt_Archive_Retention_Days_Description": "Número de dias para manter os recibos de leitura no armazenamento ativo antes de arquivar no armazenamento frio", + "Message_Read_Receipt_Archive_Cron": "Agendamento do Arquivo", + "Message_Read_Receipt_Archive_Cron_Description": "Expressão cron para o agendamento do arquivamento (por exemplo, '0 2 * * *' para diariamente às 2h da manhã)", "Message_ShowDeletedStatus": "Mostrar Status Excluído", "Message_ShowEditedStatus": "Mostrar Status Editado", "Message_ShowFormattingTips": "Exibir dicas de formatação", From e35b4dedcffa29b3c6a96aef9a3685aed17a26a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:07:52 +0000 Subject: [PATCH 09/20] Fix TypeScript type errors in ReadReceiptsArchive model Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../ee/server/cron/readReceiptsArchive.ts | 2 +- .../server/models/raw/ReadReceiptsArchive.ts | 27 ++++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.ts index 237544488f94a..fe1ca0174c7ad 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -14,7 +14,7 @@ export async function archiveOldReadReceipts(): Promise { logger.info(`Starting to archive read receipts older than ${cutoffDate.toISOString()}`); - // Find all receipts older than 30 days + // Find all receipts older than the retention period const oldReceipts = await ReadReceipts.findOlderThan(cutoffDate).toArray(); if (oldReceipts.length === 0) { diff --git a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts index 57892fcc599de..6716792d4d36c 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -1,7 +1,7 @@ -import type { IReadReceipt, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IReadReceipt, IUser, IMessage, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { IReadReceiptsModel } from '@rocket.chat/model-typings'; import { BaseRaw } from '@rocket.chat/models'; -import type { Collection, FindCursor, Db, IndexDescription } from 'mongodb'; +import type { Collection, FindCursor, Db, IndexDescription, Filter, DeleteResult, UpdateResult, Document } from 'mongodb'; export class ReadReceiptsArchiveRaw extends BaseRaw implements IReadReceiptsModel { constructor(db: Db, trash?: Collection>) { @@ -18,36 +18,43 @@ export class ReadReceiptsArchiveRaw extends BaseRaw implements IRe // Archive doesn't need all the delete methods from hot storage // But we implement them to satisfy the interface - async removeByUserId(userId: string) { + removeByUserId(userId: string): Promise { return this.deleteMany({ userId }); } - async removeByRoomId(roomId: string) { + removeByRoomId(roomId: string): Promise { return this.deleteMany({ roomId }); } - async removeByRoomIds(roomIds: string[]) { + removeByRoomIds(roomIds: string[]): Promise { return this.deleteMany({ roomId: { $in: roomIds } }); } - async removeByMessageId(messageId: string) { + removeByMessageId(messageId: string): Promise { return this.deleteMany({ messageId }); } - async removeByMessageIds(messageIds: string[]) { + removeByMessageIds(messageIds: string[]): Promise { return this.deleteMany({ messageId: { $in: messageIds } }); } - async removeByIdPinnedTimestampLimitAndUsers() { + async removeByIdPinnedTimestampLimitAndUsers( + _roomId: string, + _ignorePinned: boolean, + _ignoreDiscussion: boolean, + _ts: Filter['ts'], + _users: IUser['_id'][], + _ignoreThreads: boolean, + ): Promise { // Not needed for archive, but required by interface return { acknowledged: true, deletedCount: 0 }; } - async setPinnedByMessageId(messageId: string, pinned = true) { + setPinnedByMessageId(messageId: string, pinned = true): Promise { return this.updateMany({ messageId }, { $set: { pinned } }); } - async setAsThreadById(messageId: string) { + setAsThreadById(messageId: string): Promise { return this.updateMany({ messageId }, { $set: { tmid: messageId } }); } From ec19670a430ac492c8dc2ad7609987cc87a44878 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:34:18 +0000 Subject: [PATCH 10/20] Fix lint and TypeScript errors in read receipts archiving Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../server/cron/readReceiptsArchive.spec.ts | 22 ++++++------------- .../ee/server/cron/readReceiptsArchive.ts | 15 +++++-------- .../server/models/raw/ReadReceiptsArchive.ts | 7 +++++- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts index 739caeea398ce..db10b4e8fed98 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts @@ -1,3 +1,6 @@ +import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; + +import { settings } from '../../../app/settings/server'; import { archiveOldReadReceipts } from './readReceiptsArchive'; jest.mock('@rocket.chat/models', () => ({ @@ -35,9 +38,6 @@ jest.mock('@rocket.chat/cron', () => ({ }, })); -import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; -import { settings } from '../../../app/settings/server'; - describe('Read Receipts Archive', () => { beforeEach(() => { jest.clearAllMocks(); @@ -105,10 +105,7 @@ describe('Read Receipts Archive', () => { expect(ReadReceiptsArchive.insertMany).toHaveBeenCalledWith(oldReceipts, { ordered: false }); // Verify messages were marked - expect(Messages.updateMany).toHaveBeenCalledWith( - { _id: { $in: ['msg1', 'msg2'] } }, - { $set: { receiptsArchived: true } } - ); + expect(Messages.updateMany).toHaveBeenCalledWith({ _id: { $in: ['msg1', 'msg2'] } }, { $set: { receiptsArchived: true } }); // Verify old receipts were deleted expect(ReadReceipts.deleteMany).toHaveBeenCalledWith(expect.objectContaining({ ts: expect.any(Object) })); @@ -117,9 +114,7 @@ describe('Read Receipts Archive', () => { it('should handle duplicate key errors gracefully', async () => { (settings.get as jest.Mock).mockReturnValue(30); - const oldReceipts = [ - { _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }, - ]; + const oldReceipts = [{ _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }]; const toArrayMock = jest.fn().mockResolvedValue(oldReceipts); (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); @@ -143,9 +138,7 @@ describe('Read Receipts Archive', () => { it('should rethrow non-duplicate errors', async () => { (settings.get as jest.Mock).mockReturnValue(30); - const oldReceipts = [ - { _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }, - ]; + const oldReceipts = [{ _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }]; const toArrayMock = jest.fn().mockResolvedValue(oldReceipts); (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); @@ -156,5 +149,4 @@ describe('Read Receipts Archive', () => { await expect(archiveOldReadReceipts()).rejects.toThrow('Connection failed'); }); -}); - +}); \ No newline at end of file diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.ts index fe1ca0174c7ad..34ad8aad4a5c6 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -1,6 +1,6 @@ import { cronJobs } from '@rocket.chat/cron'; -import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; import { Logger } from '@rocket.chat/logger'; +import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; import { settings } from '../../../app/settings/server'; @@ -11,7 +11,7 @@ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; export async function archiveOldReadReceipts(): Promise { const retentionDays = settings.get('Message_Read_Receipt_Archive_Retention_Days') || 30; const cutoffDate = new Date(Date.now() - retentionDays * MILLISECONDS_PER_DAY); - + logger.info(`Starting to archive read receipts older than ${cutoffDate.toISOString()}`); // Find all receipts older than the retention period @@ -51,28 +51,25 @@ export async function archiveOldReadReceipts(): Promise { } // Mark messages as having archived receipts - const updateResult = await Messages.updateMany( - { _id: { $in: messageIds } }, - { $set: { receiptsArchived: true } } - ); + const updateResult = await Messages.updateMany({ _id: { $in: messageIds } }, { $set: { receiptsArchived: true } }); logger.info(`Marked ${updateResult.modifiedCount} messages as having archived receipts`); // Delete old receipts from hot storage const deleteResult = await ReadReceipts.deleteMany({ ts: { $lt: cutoffDate } }); logger.info(`Deleted ${deleteResult.deletedCount} old receipts from hot storage`); } catch (error) { - logger.error('Error during read receipts archiving:', error); + logger.error(`Error during read receipts archiving: ${error}`); throw error; } } export async function readReceiptsArchiveCron(): Promise { const cronSchedule = settings.get('Message_Read_Receipt_Archive_Cron') || '0 2 * * *'; - + // Remove existing job if it exists if (await cronJobs.has('ReadReceiptsArchive')) { await cronJobs.remove('ReadReceiptsArchive'); } - + return cronJobs.add('ReadReceiptsArchive', cronSchedule, async () => archiveOldReadReceipts()); } diff --git a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts index 6716792d4d36c..682445138355e 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -9,7 +9,12 @@ export class ReadReceiptsArchiveRaw extends BaseRaw implements IRe } protected override modelIndexes(): IndexDescription[] { - return [{ key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, { key: { messageId: 1 } }, { key: { userId: 1 } }, { key: { ts: 1 } }]; + return [ + { key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, + { key: { messageId: 1 } }, + { key: { userId: 1 } }, + { key: { ts: 1 } }, + ]; } findByMessageId(messageId: string): FindCursor { From c978fab21b25a78728698dffab83f34fcf01efbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:31:10 +0000 Subject: [PATCH 11/20] Add configurable batch processing for read receipts archiving with 1s delay Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../server/cron/readReceiptsArchive.spec.ts | 89 +++++++++++++++---- .../ee/server/cron/readReceiptsArchive.ts | 75 ++++++++++------ apps/meteor/server/settings/message.ts | 8 ++ packages/i18n/src/locales/en.i18n.json | 2 + packages/i18n/src/locales/pt-BR.i18n.json | 2 + packages/i18n/src/locales/pt.i18n.json | 2 + 6 files changed, 135 insertions(+), 43 deletions(-) diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts index db10b4e8fed98..298868ac6b1d2 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts @@ -38,34 +38,47 @@ jest.mock('@rocket.chat/cron', () => ({ }, })); +// Mock setTimeout to avoid actual delays in tests +jest.useFakeTimers(); + describe('Read Receipts Archive', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('should use default retention days when setting is not available', async () => { + afterEach(() => { + jest.runOnlyPendingTimers(); + }); + + it('should use default retention days and batch size when settings are not available', async () => { (settings.get as jest.Mock).mockReturnValue(undefined); - const toArrayMock = jest.fn().mockResolvedValue([]); - (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + const limitMock = jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ limit: limitMock }); await archiveOldReadReceipts(); expect(ReadReceipts.findOlderThan).toHaveBeenCalled(); + expect(limitMock).toHaveBeenCalledWith(10000); // Default batch size const cutoffDate = (ReadReceipts.findOlderThan as jest.Mock).mock.calls[0][0]; const daysDiff = Math.floor((Date.now() - cutoffDate.getTime()) / (24 * 60 * 60 * 1000)); expect(daysDiff).toBe(30); // Default 30 days }); - it('should use configured retention days', async () => { - (settings.get as jest.Mock).mockReturnValue(45); + it('should use configured retention days and batch size', async () => { + (settings.get as jest.Mock).mockImplementation((key: string) => { + if (key === 'Message_Read_Receipt_Archive_Retention_Days') return 45; + if (key === 'Message_Read_Receipt_Archive_Batch_Size') return 5000; + return undefined; + }); - const toArrayMock = jest.fn().mockResolvedValue([]); - (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + const limitMock = jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ limit: limitMock }); await archiveOldReadReceipts(); expect(ReadReceipts.findOlderThan).toHaveBeenCalled(); + expect(limitMock).toHaveBeenCalledWith(5000); // Custom batch size const cutoffDate = (ReadReceipts.findOlderThan as jest.Mock).mock.calls[0][0]; const daysDiff = Math.floor((Date.now() - cutoffDate.getTime()) / (24 * 60 * 60 * 1000)); expect(daysDiff).toBe(45); @@ -74,8 +87,8 @@ describe('Read Receipts Archive', () => { it('should not process when no old receipts found', async () => { (settings.get as jest.Mock).mockReturnValue(30); - const toArrayMock = jest.fn().mockResolvedValue([]); - (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + const limitMock = jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ limit: limitMock }); await archiveOldReadReceipts(); @@ -84,7 +97,7 @@ describe('Read Receipts Archive', () => { expect(ReadReceipts.deleteMany).not.toHaveBeenCalled(); }); - it('should archive old receipts and mark messages', async () => { + it('should archive old receipts in single batch and mark messages', async () => { (settings.get as jest.Mock).mockReturnValue(30); const oldReceipts = [ @@ -93,8 +106,8 @@ describe('Read Receipts Archive', () => { { _id: '3', messageId: 'msg1', userId: 'user3', ts: new Date('2020-01-03') }, ]; - const toArrayMock = jest.fn().mockResolvedValue(oldReceipts); - (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + const limitMock = jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue(oldReceipts) }); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ limit: limitMock }); (ReadReceiptsArchive.insertMany as jest.Mock).mockResolvedValue({ insertedCount: 3 }); (Messages.updateMany as jest.Mock).mockResolvedValue({ modifiedCount: 2 }); (ReadReceipts.deleteMany as jest.Mock).mockResolvedValue({ deletedCount: 3 }); @@ -107,8 +120,48 @@ describe('Read Receipts Archive', () => { // Verify messages were marked expect(Messages.updateMany).toHaveBeenCalledWith({ _id: { $in: ['msg1', 'msg2'] } }, { $set: { receiptsArchived: true } }); - // Verify old receipts were deleted - expect(ReadReceipts.deleteMany).toHaveBeenCalledWith(expect.objectContaining({ ts: expect.any(Object) })); + // Verify old receipts were deleted by ID + expect(ReadReceipts.deleteMany).toHaveBeenCalledWith({ _id: { $in: ['1', '2', '3'] } }); + }); + + it('should process multiple batches with delay', async () => { + (settings.get as jest.Mock).mockImplementation((key: string) => { + if (key === 'Message_Read_Receipt_Archive_Retention_Days') return 30; + if (key === 'Message_Read_Receipt_Archive_Batch_Size') return 2; + return undefined; + }); + + const batch1 = [ + { _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }, + { _id: '2', messageId: 'msg2', userId: 'user2', ts: new Date('2020-01-02') }, + ]; + const batch2 = [{ _id: '3', messageId: 'msg3', userId: 'user3', ts: new Date('2020-01-03') }]; + + let callCount = 0; + const limitMock = jest.fn().mockImplementation(() => ({ + toArray: jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) return Promise.resolve(batch1); + if (callCount === 2) return Promise.resolve(batch2); + return Promise.resolve([]); + }), + })); + + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ limit: limitMock }); + (ReadReceiptsArchive.insertMany as jest.Mock).mockResolvedValue({ insertedCount: 2 }); + (Messages.updateMany as jest.Mock).mockResolvedValue({ modifiedCount: 1 }); + (ReadReceipts.deleteMany as jest.Mock).mockResolvedValue({ deletedCount: 2 }); + + const archivePromise = archiveOldReadReceipts(); + + // Fast-forward timers for delays between batches + await jest.runAllTimersAsync(); + await archivePromise; + + // Should process 2 batches + expect(ReadReceiptsArchive.insertMany).toHaveBeenCalledTimes(2); + expect(Messages.updateMany).toHaveBeenCalledTimes(2); + expect(ReadReceipts.deleteMany).toHaveBeenCalledTimes(2); }); it('should handle duplicate key errors gracefully', async () => { @@ -116,8 +169,8 @@ describe('Read Receipts Archive', () => { const oldReceipts = [{ _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }]; - const toArrayMock = jest.fn().mockResolvedValue(oldReceipts); - (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + const limitMock = jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue(oldReceipts) }); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ limit: limitMock }); // Simulate duplicate key error const duplicateError = Object.assign(new Error('Duplicate key'), { @@ -140,8 +193,8 @@ describe('Read Receipts Archive', () => { const oldReceipts = [{ _id: '1', messageId: 'msg1', userId: 'user1', ts: new Date('2020-01-01') }]; - const toArrayMock = jest.fn().mockResolvedValue(oldReceipts); - (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ toArray: toArrayMock }); + const limitMock = jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue(oldReceipts) }); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ limit: limitMock }); // Simulate other error const otherError = new Error('Connection failed'); diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.ts index 34ad8aad4a5c6..581d72856d415 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -7,32 +7,44 @@ import { settings } from '../../../app/settings/server'; const logger = new Logger('ReadReceiptsArchive'); const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; +const BATCH_DELAY_MS = 1000; // 1 second delay between batches + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} export async function archiveOldReadReceipts(): Promise { const retentionDays = settings.get('Message_Read_Receipt_Archive_Retention_Days') || 30; + const batchSize = settings.get('Message_Read_Receipt_Archive_Batch_Size') || 10000; const cutoffDate = new Date(Date.now() - retentionDays * MILLISECONDS_PER_DAY); - logger.info(`Starting to archive read receipts older than ${cutoffDate.toISOString()}`); + logger.info(`Starting to archive read receipts older than ${cutoffDate.toISOString()}, batch size: ${batchSize}`); - // Find all receipts older than the retention period - const oldReceipts = await ReadReceipts.findOlderThan(cutoffDate).toArray(); + let totalProcessed = 0; + let batchNumber = 0; - if (oldReceipts.length === 0) { - logger.info('No read receipts to archive'); - return; - } + while (true) { + batchNumber++; + logger.info(`Processing batch ${batchNumber}...`); + + // Find receipts older than the retention period, limited by batch size + const oldReceipts = await ReadReceipts.findOlderThan(cutoffDate).limit(batchSize).toArray(); + + if (oldReceipts.length === 0) { + logger.info(`No more read receipts to archive. Total processed: ${totalProcessed}`); + break; + } - logger.info(`Found ${oldReceipts.length} read receipts to archive`); + logger.info(`Found ${oldReceipts.length} read receipts in batch ${batchNumber}`); - // Get unique message IDs from the receipts to be archived - const messageIds = [...new Set(oldReceipts.map((receipt) => receipt.messageId))]; + // Get unique message IDs from the receipts to be archived + const messageIds = [...new Set(oldReceipts.map((receipt) => receipt.messageId))]; - try { - // Insert receipts into archive collection (using insertMany with ordered: false to continue on duplicate key errors) - if (oldReceipts.length > 0) { + try { + // Insert receipts into archive collection (using insertMany with ordered: false to continue on duplicate key errors) try { await ReadReceiptsArchive.insertMany(oldReceipts, { ordered: false }); - logger.info(`Successfully archived ${oldReceipts.length} read receipts`); + logger.info(`Successfully archived ${oldReceipts.length} read receipts in batch ${batchNumber}`); } catch (error: unknown) { // If we get duplicate key errors, some receipts were already archived, which is fine // We'll continue to mark messages and delete from hot storage @@ -40,7 +52,7 @@ export async function archiveOldReadReceipts(): Promise { const mongoError = error as { code?: number; name?: string; result?: { insertedCount?: number } }; if (mongoError.code === 11000 || mongoError.name === 'MongoBulkWriteError') { const insertedCount = mongoError.result?.insertedCount || 0; - logger.info(`Archived ${insertedCount} read receipts (some were already archived)`); + logger.info(`Archived ${insertedCount} read receipts in batch ${batchNumber} (some were already archived)`); } else { throw error; } @@ -48,18 +60,31 @@ export async function archiveOldReadReceipts(): Promise { throw error; } } - } - // Mark messages as having archived receipts - const updateResult = await Messages.updateMany({ _id: { $in: messageIds } }, { $set: { receiptsArchived: true } }); - logger.info(`Marked ${updateResult.modifiedCount} messages as having archived receipts`); + // Mark messages as having archived receipts + const updateResult = await Messages.updateMany({ _id: { $in: messageIds } }, { $set: { receiptsArchived: true } }); + logger.info(`Marked ${updateResult.modifiedCount} messages as having archived receipts in batch ${batchNumber}`); + + // Delete old receipts from hot storage for this batch + const receiptIds = oldReceipts.map((receipt) => receipt._id); + const deleteResult = await ReadReceipts.deleteMany({ _id: { $in: receiptIds } }); + logger.info(`Deleted ${deleteResult.deletedCount} old receipts from hot storage in batch ${batchNumber}`); + + totalProcessed += oldReceipts.length; - // Delete old receipts from hot storage - const deleteResult = await ReadReceipts.deleteMany({ ts: { $lt: cutoffDate } }); - logger.info(`Deleted ${deleteResult.deletedCount} old receipts from hot storage`); - } catch (error) { - logger.error(`Error during read receipts archiving: ${error}`); - throw error; + // If we processed a full batch, there might be more, so wait and continue + if (oldReceipts.length === batchSize) { + logger.info(`Batch ${batchNumber} complete. Waiting ${BATCH_DELAY_MS}ms before next batch...`); + await sleep(BATCH_DELAY_MS); + } else { + // This was the last batch (partial batch) + logger.info(`Final batch ${batchNumber} complete. Total processed: ${totalProcessed}`); + break; + } + } catch (error) { + logger.error(`Error during read receipts archiving in batch ${batchNumber}: ${error}`); + throw error; + } } } diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts index dc4a210b56a8f..1c38c06189e56 100644 --- a/apps/meteor/server/settings/message.ts +++ b/apps/meteor/server/settings/message.ts @@ -81,6 +81,14 @@ export const createMessageSettings = () => i18nDescription: 'Message_Read_Receipt_Archive_Cron_Description', enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, }); + await this.add('Message_Read_Receipt_Archive_Batch_Size', 10000, { + type: 'int', + enterprise: true, + invalidValue: 10000, + modules: ['message-read-receipt'], + i18nDescription: 'Message_Read_Receipt_Archive_Batch_Size_Description', + enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, + }); }); await this.add('Message_CustomDomain_AutoLink', '', { type: 'string', diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 274e6d1fce048..aecf30a1f80d8 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3484,6 +3484,8 @@ "Message_Read_Receipt_Archive_Retention_Days_Description": "Number of days to keep read receipts in hot storage before archiving to cold storage", "Message_Read_Receipt_Archive_Cron": "Archive Cron Schedule", "Message_Read_Receipt_Archive_Cron_Description": "Cron expression for the archiving schedule (e.g., '0 2 * * *' for daily at 2 AM)", + "Message_Read_Receipt_Archive_Batch_Size": "Archive Batch Size", + "Message_Read_Receipt_Archive_Batch_Size_Description": "Number of read receipts to process per batch during archiving. Lower values reduce server load but take longer to complete.", "Message_ShowDeletedStatus": "Show Deleted Status", "Message_ShowEditedStatus": "Show Edited Status", "Message_ShowFormattingTips": "Show Formatting Tips", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index e13c361e1bf78..f4fb4e2ce4931 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -3290,6 +3290,8 @@ "Message_Read_Receipt_Archive_Retention_Days_Description": "Número de dias para manter as confirmações de leitura no armazenamento ativo antes de arquivar no armazenamento frio", "Message_Read_Receipt_Archive_Cron": "Agendamento do Arquivo", "Message_Read_Receipt_Archive_Cron_Description": "Expressão cron para o agendamento do arquivamento (por exemplo, '0 2 * * *' para diariamente às 2h da manhã)", + "Message_Read_Receipt_Archive_Batch_Size": "Tamanho do Lote de Arquivo", + "Message_Read_Receipt_Archive_Batch_Size_Description": "Número de confirmações de leitura a processar por lote durante o arquivamento. Valores mais baixos reduzem a carga do servidor, mas levam mais tempo para concluir.", "Message_ShowDeletedStatus": "Mostrar status excluído", "Message_ShowEditedStatus": "Mostrar status editado", "Message_ShowFormattingTips": "Exibir dicas de formatação", diff --git a/packages/i18n/src/locales/pt.i18n.json b/packages/i18n/src/locales/pt.i18n.json index 21caeede9f39e..a4472b0d91f6d 100644 --- a/packages/i18n/src/locales/pt.i18n.json +++ b/packages/i18n/src/locales/pt.i18n.json @@ -1705,6 +1705,8 @@ "Message_Read_Receipt_Archive_Retention_Days_Description": "Número de dias para manter os recibos de leitura no armazenamento ativo antes de arquivar no armazenamento frio", "Message_Read_Receipt_Archive_Cron": "Agendamento do Arquivo", "Message_Read_Receipt_Archive_Cron_Description": "Expressão cron para o agendamento do arquivamento (por exemplo, '0 2 * * *' para diariamente às 2h da manhã)", + "Message_Read_Receipt_Archive_Batch_Size": "Tamanho do Lote de Arquivo", + "Message_Read_Receipt_Archive_Batch_Size_Description": "Número de recibos de leitura a processar por lote durante o arquivamento. Valores mais baixos reduzem a carga do servidor, mas levam mais tempo para concluir.", "Message_ShowDeletedStatus": "Mostrar Status Excluído", "Message_ShowEditedStatus": "Mostrar Status Editado", "Message_ShowFormattingTips": "Exibir dicas de formatação", From 5a57b2e850f10de1e59b815dfc4029edc5ab8817 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:25:10 +0000 Subject: [PATCH 12/20] Fix lint issues: import order, await-in-loop, and constant condition Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../meteor/ee/server/cron/readReceiptsArchive.spec.ts | 4 ++-- apps/meteor/ee/server/cron/readReceiptsArchive.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts index 298868ac6b1d2..24d64d03470fe 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts @@ -1,7 +1,7 @@ import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; -import { settings } from '../../../app/settings/server'; import { archiveOldReadReceipts } from './readReceiptsArchive'; +import { settings } from '../../../app/settings/server'; jest.mock('@rocket.chat/models', () => ({ ReadReceipts: { @@ -202,4 +202,4 @@ describe('Read Receipts Archive', () => { await expect(archiveOldReadReceipts()).rejects.toThrow('Connection failed'); }); -}); \ No newline at end of file +}); diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.ts index 581d72856d415..a0e60d6028ed3 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -22,12 +22,15 @@ export async function archiveOldReadReceipts(): Promise { let totalProcessed = 0; let batchNumber = 0; + let hasMore = true; - while (true) { + // eslint-disable-next-line no-await-in-loop + while (hasMore) { batchNumber++; logger.info(`Processing batch ${batchNumber}...`); // Find receipts older than the retention period, limited by batch size + // eslint-disable-next-line no-await-in-loop const oldReceipts = await ReadReceipts.findOlderThan(cutoffDate).limit(batchSize).toArray(); if (oldReceipts.length === 0) { @@ -43,6 +46,7 @@ export async function archiveOldReadReceipts(): Promise { try { // Insert receipts into archive collection (using insertMany with ordered: false to continue on duplicate key errors) try { + // eslint-disable-next-line no-await-in-loop await ReadReceiptsArchive.insertMany(oldReceipts, { ordered: false }); logger.info(`Successfully archived ${oldReceipts.length} read receipts in batch ${batchNumber}`); } catch (error: unknown) { @@ -62,11 +66,13 @@ export async function archiveOldReadReceipts(): Promise { } // Mark messages as having archived receipts + // eslint-disable-next-line no-await-in-loop const updateResult = await Messages.updateMany({ _id: { $in: messageIds } }, { $set: { receiptsArchived: true } }); logger.info(`Marked ${updateResult.modifiedCount} messages as having archived receipts in batch ${batchNumber}`); // Delete old receipts from hot storage for this batch const receiptIds = oldReceipts.map((receipt) => receipt._id); + // eslint-disable-next-line no-await-in-loop const deleteResult = await ReadReceipts.deleteMany({ _id: { $in: receiptIds } }); logger.info(`Deleted ${deleteResult.deletedCount} old receipts from hot storage in batch ${batchNumber}`); @@ -75,11 +81,12 @@ export async function archiveOldReadReceipts(): Promise { // If we processed a full batch, there might be more, so wait and continue if (oldReceipts.length === batchSize) { logger.info(`Batch ${batchNumber} complete. Waiting ${BATCH_DELAY_MS}ms before next batch...`); + // eslint-disable-next-line no-await-in-loop await sleep(BATCH_DELAY_MS); } else { // This was the last batch (partial batch) logger.info(`Final batch ${batchNumber} complete. Total processed: ${totalProcessed}`); - break; + hasMore = false; } } catch (error) { logger.error(`Error during read receipts archiving in batch ${batchNumber}: ${error}`); From d264a0b11f78f8c958b63f4b11161440744958a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:57:50 +0000 Subject: [PATCH 13/20] Add secondary read preference for archived read receipts queries Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../meteor/ee/server/models/raw/ReadReceiptsArchive.ts | 10 ++++++++-- packages/models/src/index.ts | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts index 682445138355e..06b0cd6fc059e 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -1,11 +1,16 @@ import type { IReadReceipt, IUser, IMessage, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { IReadReceiptsModel } from '@rocket.chat/model-typings'; -import { BaseRaw } from '@rocket.chat/models'; +import { BaseRaw, readSecondaryPreferred } from '@rocket.chat/models'; import type { Collection, FindCursor, Db, IndexDescription, Filter, DeleteResult, UpdateResult, Document } from 'mongodb'; export class ReadReceiptsArchiveRaw extends BaseRaw implements IReadReceiptsModel { + private secondaryCollection: Collection; + constructor(db: Db, trash?: Collection>) { super(db, 'read_receipts_archive', trash); + + // Create a secondary collection for reads to prefer secondary replicas + this.secondaryCollection = db.collection('read_receipts_archive', { readPreference: readSecondaryPreferred(db) }); } protected override modelIndexes(): IndexDescription[] { @@ -18,7 +23,8 @@ export class ReadReceiptsArchiveRaw extends BaseRaw implements IRe } findByMessageId(messageId: string): FindCursor { - return this.find({ messageId }); + // Use secondary collection for reads to prefer reading from secondary replicas + return this.secondaryCollection.find({ messageId }); } // Archive doesn't need all the delete methods from hot storage diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index beeb733c5cc23..7f55b507fe235 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -122,6 +122,7 @@ export * from './dummy/ReadReceipts'; export { registerModel } from './proxify'; export { type Updater, UpdaterImpl } from './updater'; +export { readSecondaryPreferred } from './readSecondaryPreferred'; export const Apps = proxify('IAppsModel'); export const AppsPersistence = proxify('IAppsPersistenceModel'); From 988db4f42859ca7d948c45517316db6be02e6b4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:00:58 +0000 Subject: [PATCH 14/20] Pass read preference directly to find queries instead of creating secondary collection Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- .../ee/server/models/raw/ReadReceiptsArchive.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts index 06b0cd6fc059e..4dad0056e6ee0 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -4,13 +4,8 @@ import { BaseRaw, readSecondaryPreferred } from '@rocket.chat/models'; import type { Collection, FindCursor, Db, IndexDescription, Filter, DeleteResult, UpdateResult, Document } from 'mongodb'; export class ReadReceiptsArchiveRaw extends BaseRaw implements IReadReceiptsModel { - private secondaryCollection: Collection; - constructor(db: Db, trash?: Collection>) { super(db, 'read_receipts_archive', trash); - - // Create a secondary collection for reads to prefer secondary replicas - this.secondaryCollection = db.collection('read_receipts_archive', { readPreference: readSecondaryPreferred(db) }); } protected override modelIndexes(): IndexDescription[] { @@ -23,8 +18,8 @@ export class ReadReceiptsArchiveRaw extends BaseRaw implements IRe } findByMessageId(messageId: string): FindCursor { - // Use secondary collection for reads to prefer reading from secondary replicas - return this.secondaryCollection.find({ messageId }); + // Pass read preference directly to the find query to prefer reading from secondary replicas + return this.find({ messageId }, { readPreference: readSecondaryPreferred(this.db) }); } // Archive doesn't need all the delete methods from hot storage @@ -70,6 +65,7 @@ export class ReadReceiptsArchiveRaw extends BaseRaw implements IRe } findOlderThan(date: Date): FindCursor { - return this.find({ ts: { $lt: date } }); + // Pass read preference directly to the find query to prefer reading from secondary replicas + return this.find({ ts: { $lt: date } }, { readPreference: readSecondaryPreferred(this.db) }); } } From 9948d142a776597361a1f894fb329bb85d1f57e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:23:36 +0000 Subject: [PATCH 15/20] Fix TypeScript error: call readSecondaryPreferred() without db parameter Co-authored-by: rodrigok <234261+rodrigok@users.noreply.github.com> --- apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts index 4dad0056e6ee0..50402a21e1d1a 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -19,7 +19,7 @@ export class ReadReceiptsArchiveRaw extends BaseRaw implements IRe findByMessageId(messageId: string): FindCursor { // Pass read preference directly to the find query to prefer reading from secondary replicas - return this.find({ messageId }, { readPreference: readSecondaryPreferred(this.db) }); + return this.find({ messageId }, { readPreference: readSecondaryPreferred() }); } // Archive doesn't need all the delete methods from hot storage @@ -66,6 +66,6 @@ export class ReadReceiptsArchiveRaw extends BaseRaw implements IRe findOlderThan(date: Date): FindCursor { // Pass read preference directly to the find query to prefer reading from secondary replicas - return this.find({ ts: { $lt: date } }, { readPreference: readSecondaryPreferred(this.db) }); + return this.find({ ts: { $lt: date } }, { readPreference: readSecondaryPreferred() }); } } From 60efbfcd4208262790228f98e302490285387f7f Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Thu, 5 Mar 2026 20:15:31 -0300 Subject: [PATCH 16/20] Do not save extra data for read receipt & do not remove receipts when based on old extra data filters --- .../lib/server/functions/cleanRoomHistory.ts | 10 +---- .../app/message-pin/server/pinMessage.ts | 8 +--- .../app/slackbridge/server/SlackAdapter.ts | 8 +--- apps/meteor/app/threads/server/functions.ts | 7 +-- apps/meteor/definition/IRoomTypeConfig.ts | 13 +----- .../lib/message-read-receipt/ReadReceipt.ts | 25 +++++------ .../ee/server/models/raw/ReadReceipts.ts | 44 +------------------ .../server/models/raw/ReadReceiptsArchive.ts | 24 +--------- .../server/lib/rooms/roomCoordinator.ts | 5 +-- .../server/lib/rooms/roomTypes/livechat.ts | 5 --- .../src/models/IReadReceiptsModel.ts | 14 +----- packages/models/src/dummy/ReadReceipts.ts | 23 +--------- 12 files changed, 26 insertions(+), 160 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts index 4b5e43857243f..1e3e3faeb15b5 100644 --- a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts +++ b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts @@ -1,6 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { IRoom } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Subscriptions, ReadReceipts, ReadReceiptsArchive, 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'; @@ -143,13 +143,7 @@ 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); - await ReadReceiptsArchive.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, uids, ignoreThreads); - } else if (selectedMessageIds) { + if (limit && selectedMessageIds) { await ReadReceipts.removeByMessageIds(selectedMessageIds); await ReadReceiptsArchive.removeByMessageIds(selectedMessageIds); } diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 204868d9dcda3..319515db3b5f4 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -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'; @@ -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); } @@ -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, }); diff --git a/apps/meteor/app/slackbridge/server/SlackAdapter.ts b/apps/meteor/app/slackbridge/server/SlackAdapter.ts index c0eae9cfb3076..831ca8a67cbce 100644 --- a/apps/meteor/app/slackbridge/server/SlackAdapter.ts +++ b/apps/meteor/app/slackbridge/server/SlackAdapter.ts @@ -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'; @@ -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; @@ -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); - } } } } diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index b747a8d5d9555..a7c28000fff17 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -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, @@ -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 })); diff --git a/apps/meteor/definition/IRoomTypeConfig.ts b/apps/meteor/definition/IRoomTypeConfig.ts index 0a8e7161c6f06..67aab132c4dd2 100644 --- a/apps/meteor/definition/IRoomTypeConfig.ts +++ b/apps/meteor/definition/IRoomTypeConfig.ts @@ -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'; @@ -106,7 +96,6 @@ export interface IRoomTypeServerDirectives { ) => Promise<{ title: string | undefined; text: string; name: string | undefined }>; getMsgSender: (message: IMessage) => Promise; includeInRoomSearch: () => boolean; - getReadReceiptsExtraData: (message: IMessage) => Partial; includeInDashboard: () => boolean; roomFind?: (rid: string) => Promise | Promise | IRoom | undefined; } diff --git a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts index f64987a4ff781..89e04a8124dd7 100644 --- a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts +++ b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts @@ -1,12 +1,10 @@ import { api } from '@rocket.chat/core-services'; import type { IMessage, IRoom, IReadReceipt, IReadReceiptWithUser } from '@rocket.chat/core-typings'; -import { LivechatVisitors, ReadReceipts, ReadReceiptsArchive, Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models'; -import { Random } from '@rocket.chat/random'; +import { ReadReceipts, ReadReceiptsArchive, Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../../../app/lib/server/lib/notifyListener'; import { settings } from '../../../../app/settings/server'; import { SystemLogger } from '../../../../server/lib/logger/system'; -import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; // debounced function by roomId, so multiple calls within 2 seconds to same roomId runs only once const list: Record = {}; @@ -62,7 +60,7 @@ class ReadReceiptClass { updateMessages(room); } - async markMessageAsReadBySender(message: IMessage, { _id: roomId, t }: { _id: string; t: string }, userId: string) { + async markMessageAsReadBySender(message: IMessage, { _id: roomId }: { _id: string }, userId: string) { if (!settings.get('Message_Read_Receipt_Enabled')) { return; } @@ -82,14 +80,12 @@ class ReadReceiptClass { } } - const extraData = roomCoordinator.getRoomDirectives(t).getReadReceiptsExtraData(message); void this.storeReadReceipts( () => { return Promise.resolve([message]); }, roomId, userId, - extraData, ); } @@ -118,7 +114,6 @@ class ReadReceiptClass { getMessages: () => Promise[]>, roomId: string, userId: string, - extraData: Partial = {}, ) { if (settings.get('Message_Read_Receipt_Store_Users')) { const ts = new Date(); @@ -128,11 +123,6 @@ class ReadReceiptClass { userId, messageId: message._id, ts, - ...(message.t && { t: message.t }), - ...(message.pinned && { pinned: true }), - ...(message.drid && { drid: message.drid }), - ...(message.tmid && { tmid: message.tmid }), - ...extraData, })); if (receipts.length === 0) { @@ -160,12 +150,17 @@ class ReadReceiptClass { // Combine receipts from both storages const receipts = [...hotReceipts, ...coldReceipts]; + // get unique receipts user ids + const userIds = [...new Set(receipts.map((receipt) => receipt.userId))]; + + // get users for the receipts + const users = await Users.findByIds(userIds, { projection: { username: 1, name: 1 } }).toArray(); + const usersMap = new Map(users.map((user) => [user._id, user])); + return Promise.all( receipts.map(async (receipt) => ({ ...receipt, - user: (receipt.token - ? await LivechatVisitors.getVisitorByToken(receipt.token, { projection: { username: 1, name: 1 } }) - : await Users.findOneById(receipt.userId, { projection: { username: 1, name: 1, token: 1 } })) as IReadReceiptWithUser['user'], + user: usersMap.get(receipt.userId) as IReadReceiptWithUser['user'], })), ); } diff --git a/apps/meteor/ee/server/models/raw/ReadReceipts.ts b/apps/meteor/ee/server/models/raw/ReadReceipts.ts index 31ee523243b4a..8d5a06d291aa8 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceipts.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceipts.ts @@ -1,7 +1,7 @@ -import type { IUser, IMessage, IReadReceipt, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IReadReceipt, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { IReadReceiptsModel } from '@rocket.chat/model-typings'; import { BaseRaw } from '@rocket.chat/models'; -import type { Collection, FindCursor, Db, IndexDescription, DeleteResult, Filter, UpdateResult, Document } from 'mongodb'; +import type { Collection, FindCursor, Db, IndexDescription, DeleteResult } from 'mongodb'; export class ReadReceiptsRaw extends BaseRaw implements IReadReceiptsModel { constructor(db: Db, trash?: Collection>) { @@ -36,46 +36,6 @@ export class ReadReceiptsRaw extends BaseRaw implements IReadRecei return this.deleteMany({ messageId: { $in: messageIds } }); } - async removeByIdPinnedTimestampLimitAndUsers( - roomId: string, - ignorePinned: boolean, - ignoreDiscussion: boolean, - ts: Filter['ts'], - users: IUser['_id'][], - ignoreThreads: boolean, - ): Promise { - const query: Filter = { - roomId, - ts, - }; - - if (ignorePinned) { - query.pinned = { $ne: true }; - } - - if (ignoreDiscussion) { - query.drid = { $exists: false }; - } - - if (ignoreThreads) { - query.tmid = { $exists: false }; - } - - if (users.length) { - query.userId = { $in: users }; - } - - return this.deleteMany(query); - } - - setPinnedByMessageId(messageId: string, pinned = true): Promise { - return this.updateMany({ messageId }, { $set: { pinned } }); - } - - setAsThreadById(messageId: string): Promise { - return this.updateMany({ messageId }, { $set: { tmid: messageId } }); - } - findOlderThan(date: Date): FindCursor { return this.find({ ts: { $lt: date } }); } diff --git a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts index 50402a21e1d1a..4a3c49c65a35e 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -1,7 +1,7 @@ -import type { IReadReceipt, IUser, IMessage, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IReadReceipt, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { IReadReceiptsModel } from '@rocket.chat/model-typings'; import { BaseRaw, readSecondaryPreferred } from '@rocket.chat/models'; -import type { Collection, FindCursor, Db, IndexDescription, Filter, DeleteResult, UpdateResult, Document } from 'mongodb'; +import type { Collection, FindCursor, Db, IndexDescription, DeleteResult } from 'mongodb'; export class ReadReceiptsArchiveRaw extends BaseRaw implements IReadReceiptsModel { constructor(db: Db, trash?: Collection>) { @@ -44,26 +44,6 @@ export class ReadReceiptsArchiveRaw extends BaseRaw implements IRe return this.deleteMany({ messageId: { $in: messageIds } }); } - async removeByIdPinnedTimestampLimitAndUsers( - _roomId: string, - _ignorePinned: boolean, - _ignoreDiscussion: boolean, - _ts: Filter['ts'], - _users: IUser['_id'][], - _ignoreThreads: boolean, - ): Promise { - // Not needed for archive, but required by interface - return { acknowledged: true, deletedCount: 0 }; - } - - setPinnedByMessageId(messageId: string, pinned = true): Promise { - return this.updateMany({ messageId }, { $set: { pinned } }); - } - - setAsThreadById(messageId: string): Promise { - return this.updateMany({ messageId }, { $set: { tmid: messageId } }); - } - findOlderThan(date: Date): FindCursor { // Pass read preference directly to the find query to prefer reading from secondary replicas return this.find({ ts: { $lt: date } }, { readPreference: readSecondaryPreferred() }); diff --git a/apps/meteor/server/lib/rooms/roomCoordinator.ts b/apps/meteor/server/lib/rooms/roomCoordinator.ts index 54e35aafdde50..941883b132f2e 100644 --- a/apps/meteor/server/lib/rooms/roomCoordinator.ts +++ b/apps/meteor/server/lib/rooms/roomCoordinator.ts @@ -1,5 +1,5 @@ import { getUserDisplayName } from '@rocket.chat/core-typings'; -import type { IRoom, RoomType, IUser, IMessage, IReadReceipt, ValueOf, AtLeast } from '@rocket.chat/core-typings'; +import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { settings } from '../../../app/settings/server'; @@ -56,9 +56,6 @@ class RoomCoordinatorServer extends RoomCoordinator { includeInRoomSearch(): boolean { return false; }, - getReadReceiptsExtraData(_message: IMessage): Partial { - return {}; - }, includeInDashboard(): boolean { return false; }, diff --git a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts index 7a00f75796c02..350b8d0714f8e 100644 --- a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts +++ b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts @@ -44,9 +44,4 @@ roomCoordinator.add(LivechatRoomType, { return LivechatVisitors.findOneEnabledById(message.u._id); } }, - - getReadReceiptsExtraData(message) { - const { token } = message as any; - return { token }; - }, } as AtLeast); diff --git a/packages/model-typings/src/models/IReadReceiptsModel.ts b/packages/model-typings/src/models/IReadReceiptsModel.ts index 7878047e7ded3..0b4de325dcfc3 100644 --- a/packages/model-typings/src/models/IReadReceiptsModel.ts +++ b/packages/model-typings/src/models/IReadReceiptsModel.ts @@ -1,5 +1,5 @@ -import type { IReadReceipt, IUser, IMessage } from '@rocket.chat/core-typings'; -import type { FindCursor, DeleteResult, UpdateResult, Document, Filter } from 'mongodb'; +import type { IReadReceipt } from '@rocket.chat/core-typings'; +import type { FindCursor, DeleteResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -10,15 +10,5 @@ export interface IReadReceiptsModel extends IBaseModel { removeByRoomIds(roomIds: string[]): Promise; removeByMessageId(messageId: string): Promise; removeByMessageIds(messageIds: string[]): Promise; - removeByIdPinnedTimestampLimitAndUsers( - roomId: string, - ignorePinned: boolean, - ignoreDiscussion: boolean, - ts: Filter['ts'], - users: IUser['_id'][], - ignoreThreads: boolean, - ): Promise; - setPinnedByMessageId(messageId: string, pinned?: boolean): Promise; - setAsThreadById(messageId: string): Promise; findOlderThan(date: Date): FindCursor; } diff --git a/packages/models/src/dummy/ReadReceipts.ts b/packages/models/src/dummy/ReadReceipts.ts index aa3a4311e29e0..b6e444ba18017 100644 --- a/packages/models/src/dummy/ReadReceipts.ts +++ b/packages/models/src/dummy/ReadReceipts.ts @@ -1,6 +1,6 @@ -import type { IUser, IMessage, IReadReceipt } from '@rocket.chat/core-typings'; +import type { IReadReceipt } from '@rocket.chat/core-typings'; import type { IReadReceiptsModel } from '@rocket.chat/model-typings'; -import type { FindCursor, DeleteResult, Filter, UpdateResult, Document } from 'mongodb'; +import type { FindCursor, DeleteResult } from 'mongodb'; import { BaseDummy } from './BaseDummy'; @@ -33,25 +33,6 @@ export class ReadReceiptsDummy extends BaseDummy implements IReadR return this.deleteMany({}); } - async removeByIdPinnedTimestampLimitAndUsers( - _roomId: string, - _ignorePinned: boolean, - _ignoreDiscussion: boolean, - _ts: Filter['ts'], - _users: IUser['_id'][], - _ignoreThreads: boolean, - ): Promise { - return this.deleteMany({}); - } - - setPinnedByMessageId(_messageId: string, _pinned = true): Promise { - return this.updateMany({}, {}); - } - - setAsThreadById(_messageId: string): Promise { - return this.updateMany({}, {}); - } - findOlderThan(_date: Date): FindCursor { return this.find({}); } From 6f23eed017649e30be32f0d3ed89fb8da981ab0f Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Thu, 5 Mar 2026 20:16:58 -0300 Subject: [PATCH 17/20] Add setting to enable cold storage --- .../ee/server/cron/readReceiptsArchive.ts | 6 +++++ .../ee/server/startup/readReceiptsArchive.ts | 4 ++++ apps/meteor/server/settings/message.ts | 23 ++++++++++++++++--- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/meteor/ee/server/cron/readReceiptsArchive.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.ts index a0e60d6028ed3..3500b2eab7ae8 100644 --- a/apps/meteor/ee/server/cron/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -103,5 +103,11 @@ export async function readReceiptsArchiveCron(): Promise { await cronJobs.remove('ReadReceiptsArchive'); } + if (!settings.get('Message_Read_Receipt_Archive_Enabled')) { + return; + } + + logger.info(`Scheduling read receipts archive cron job with schedule: ${cronSchedule}`); + return cronJobs.add('ReadReceiptsArchive', cronSchedule, async () => archiveOldReadReceipts()); } diff --git a/apps/meteor/ee/server/startup/readReceiptsArchive.ts b/apps/meteor/ee/server/startup/readReceiptsArchive.ts index 514dda721ec94..7d7e21adbb028 100644 --- a/apps/meteor/ee/server/startup/readReceiptsArchive.ts +++ b/apps/meteor/ee/server/startup/readReceiptsArchive.ts @@ -10,3 +10,7 @@ settings.watch('Message_Read_Receipt_Archive_Cron', async (value) => { await readReceiptsArchiveCron(); } }); + +settings.watch('Message_Read_Receipt_Archive_Enabled', async () => { + await readReceiptsArchiveCron(); +}); diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts index 1c38c06189e56..2093db7bae3df 100644 --- a/apps/meteor/server/settings/message.ts +++ b/apps/meteor/server/settings/message.ts @@ -65,13 +65,24 @@ export const createMessageSettings = () => public: true, enableQuery: { _id: 'Message_Read_Receipt_Enabled', value: true }, }); + await this.add('Message_Read_Receipt_Archive_Enabled', true, { + type: 'boolean', + enterprise: true, + invalidValue: false, + modules: ['message-read-receipt'], + i18nDescription: 'Message_Read_Receipt_Archive_Enabled_Description', + enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, + }); await this.add('Message_Read_Receipt_Archive_Retention_Days', 30, { type: 'int', enterprise: true, invalidValue: 30, modules: ['message-read-receipt'], i18nDescription: 'Message_Read_Receipt_Archive_Retention_Days_Description', - enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, + enableQuery: [ + { _id: 'Message_Read_Receipt_Store_Users', value: true }, + { _id: 'Message_Read_Receipt_Archive_Enabled', value: true }, + ], }); await this.add('Message_Read_Receipt_Archive_Cron', '0 2 * * *', { type: 'string', @@ -79,7 +90,10 @@ export const createMessageSettings = () => invalidValue: '0 2 * * *', modules: ['message-read-receipt'], i18nDescription: 'Message_Read_Receipt_Archive_Cron_Description', - enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, + enableQuery: [ + { _id: 'Message_Read_Receipt_Store_Users', value: true }, + { _id: 'Message_Read_Receipt_Archive_Enabled', value: true }, + ], }); await this.add('Message_Read_Receipt_Archive_Batch_Size', 10000, { type: 'int', @@ -87,7 +101,10 @@ export const createMessageSettings = () => invalidValue: 10000, modules: ['message-read-receipt'], i18nDescription: 'Message_Read_Receipt_Archive_Batch_Size_Description', - enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, + enableQuery: [ + { _id: 'Message_Read_Receipt_Store_Users', value: true }, + { _id: 'Message_Read_Receipt_Archive_Enabled', value: true }, + ], }); }); await this.add('Message_CustomDomain_AutoLink', '', { From 45c33c918c0938ed84ce61f455ae70fd45d6b53c Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Thu, 5 Mar 2026 20:30:00 -0300 Subject: [PATCH 18/20] Use concatenation of message id and user id on _id to replace the unique index --- .../ee/server/lib/message-read-receipt/ReadReceipt.ts | 2 +- apps/meteor/ee/server/models/raw/ReadReceipts.ts | 2 +- apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts | 7 +------ packages/core-typings/src/IReadReceipt.ts | 9 ++------- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts index 89e04a8124dd7..6f922711c3621 100644 --- a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts +++ b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts @@ -118,7 +118,7 @@ class ReadReceiptClass { if (settings.get('Message_Read_Receipt_Store_Users')) { const ts = new Date(); const receipts = (await getMessages()).map((message) => ({ - _id: Random.id(), + _id: message._id + userId, roomId, userId, messageId: message._id, diff --git a/apps/meteor/ee/server/models/raw/ReadReceipts.ts b/apps/meteor/ee/server/models/raw/ReadReceipts.ts index 8d5a06d291aa8..43e029d3515a7 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceipts.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceipts.ts @@ -9,7 +9,7 @@ export class ReadReceiptsRaw extends BaseRaw implements IReadRecei } protected override modelIndexes(): IndexDescription[] { - return [{ key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, { key: { messageId: 1 } }, { key: { userId: 1 } }]; + return [{ key: { messageId: 1 } }, { key: { userId: 1 } }, { key: { roomId: 1 } }, { key: { ts: -1 } }]; } findByMessageId(messageId: string): FindCursor { diff --git a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts index 4a3c49c65a35e..2058056106f38 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -9,12 +9,7 @@ export class ReadReceiptsArchiveRaw extends BaseRaw implements IRe } protected override modelIndexes(): IndexDescription[] { - return [ - { key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, - { key: { messageId: 1 } }, - { key: { userId: 1 } }, - { key: { ts: 1 } }, - ]; + return [{ key: { messageId: 1 } }, { key: { userId: 1 } }, { key: { roomId: 1 } }, { key: { ts: -1 } }]; } findByMessageId(messageId: string): FindCursor { diff --git a/packages/core-typings/src/IReadReceipt.ts b/packages/core-typings/src/IReadReceipt.ts index 1b4941cf1d238..6a3a3f6e3b27b 100644 --- a/packages/core-typings/src/IReadReceipt.ts +++ b/packages/core-typings/src/IReadReceipt.ts @@ -3,16 +3,11 @@ import type { IRoom } from './IRoom'; import type { IUser } from './IUser'; export interface IReadReceipt { - token?: string; + _id: string; messageId: IMessage['_id']; roomId: IRoom['_id']; - ts: Date; - t?: IMessage['t']; - pinned?: IMessage['pinned']; - drid?: IMessage['drid']; - tmid?: IMessage['tmid']; userId: IUser['_id']; - _id: string; + ts: Date; } export interface IReadReceiptWithUser extends IReadReceipt { From dff18c4c862dc777d04115cf6214418732028370 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 7 Mar 2026 15:27:29 -0300 Subject: [PATCH 19/20] Add migration to disable receipts archive if existent dataset is big --- ...disable-archive-for-large-read-receipts.ts | 21 +++++++++++++++++++ .../server/startup/dataMigrations/index.ts | 1 + 2 files changed, 22 insertions(+) create mode 100644 apps/meteor/server/startup/dataMigrations/00001_disable-archive-for-large-read-receipts.ts diff --git a/apps/meteor/server/startup/dataMigrations/00001_disable-archive-for-large-read-receipts.ts b/apps/meteor/server/startup/dataMigrations/00001_disable-archive-for-large-read-receipts.ts new file mode 100644 index 0000000000000..a5619ef1c91a9 --- /dev/null +++ b/apps/meteor/server/startup/dataMigrations/00001_disable-archive-for-large-read-receipts.ts @@ -0,0 +1,21 @@ +import { Settings, ReadReceipts } from '@rocket.chat/models'; + +import { addDataMigration } from '../../lib/dataMigrations'; + +addDataMigration({ + order: 1, + id: 'disable-archive-for-large-read-receipts', + description: 'Do not enable read receipts archive by default if there are more than 1 million records in the read receipts collection', + strategy: 'once', + direction: 'upgrade', + requiresManualReversion: false, + async run() { + const count = await ReadReceipts.col.estimatedDocumentCount(); + + if (count <= 1_000_000) { + return; + } + + await Settings.updateOne({ _id: 'Message_Read_Receipt_Archive_Enabled' }, { $set: { value: false } }); + }, +}); diff --git a/apps/meteor/server/startup/dataMigrations/index.ts b/apps/meteor/server/startup/dataMigrations/index.ts index 220b9038d916c..c0bd789f699af 100644 --- a/apps/meteor/server/startup/dataMigrations/index.ts +++ b/apps/meteor/server/startup/dataMigrations/index.ts @@ -1,2 +1,3 @@ // Data migrations are imported here. // Use `npm run data-migration:add ` to generate a new one. +import './00001_disable-archive-for-large-read-receipts'; From 0607593521e7e0ede1049cc3bcc0aa356939a909 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Mon, 9 Mar 2026 16:30:15 -0300 Subject: [PATCH 20/20] Add alerts for the setting to enable read receipt archive --- apps/meteor/server/settings/message.ts | 1 + packages/i18n/src/locales/en.i18n.json | 3 +++ packages/i18n/src/locales/pt-BR.i18n.json | 3 +++ packages/i18n/src/locales/pt.i18n.json | 3 +++ 4 files changed, 10 insertions(+) diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts index 2093db7bae3df..5e63b9429239c 100644 --- a/apps/meteor/server/settings/message.ts +++ b/apps/meteor/server/settings/message.ts @@ -71,6 +71,7 @@ export const createMessageSettings = () => invalidValue: false, modules: ['message-read-receipt'], i18nDescription: 'Message_Read_Receipt_Archive_Enabled_Description', + alert: 'Message_Read_Receipt_Archive_Enabled_Alert', enableQuery: { _id: 'Message_Read_Receipt_Store_Users', value: true }, }); await this.add('Message_Read_Receipt_Archive_Retention_Days', 30, { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index aecf30a1f80d8..9abb0050dbdc3 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3480,6 +3480,9 @@ "Message_Read_Receipt_Enabled": "Show Read Receipts", "Message_Read_Receipt_Store_Users": "Detailed Read Receipts", "Message_Read_Receipt_Store_Users_Description": "Shows each user's read receipts", + "Message_Read_Receipt_Archive_Enabled": "Enable Read Receipts Archive", + "Message_Read_Receipt_Archive_Enabled_Description": "When enabled, read receipts older than the retention period will be moved from hot storage to cold storage.", + "Message_Read_Receipt_Archive_Enabled_Alert": "Warning: Enabling this on a workspace with a large volume of read receipts may cause the system to process the migration to cold storage for an extended period, potentially impacting production performance. It is recommended to clean up or manually migrate old read receipt data before enabling this setting.", "Message_Read_Receipt_Archive_Retention_Days": "Archive Retention Days", "Message_Read_Receipt_Archive_Retention_Days_Description": "Number of days to keep read receipts in hot storage before archiving to cold storage", "Message_Read_Receipt_Archive_Cron": "Archive Cron Schedule", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index f4fb4e2ce4931..cf7a1ea59d17c 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -3286,6 +3286,9 @@ "Message_Read_Receipt_Enabled": "Mostrar confirmação de leitura", "Message_Read_Receipt_Store_Users": "Confirmações de leitura detalhadas", "Message_Read_Receipt_Store_Users_Description": "Mostra os recibos de leitura de cada usuário", + "Message_Read_Receipt_Archive_Enabled": "Habilitar Arquivo de Confirmações de Leitura", + "Message_Read_Receipt_Archive_Enabled_Description": "Quando habilitado, as confirmações de leitura mais antigas que o período de retenção serão movidas do armazenamento ativo para o armazenamento frio.", + "Message_Read_Receipt_Archive_Enabled_Alert": "Atenção: Habilitar esta opção em um workspace com grande volume de confirmações de leitura pode fazer com que o sistema processe a migração para o armazenamento frio por um período muito prolongado, afetando o desempenho em produção. É aconselhável limpar os dados de confirmações de leitura ou migrá-los manualmente antes de ativar esta configuração.", "Message_Read_Receipt_Archive_Retention_Days": "Dias de Retenção no Arquivo", "Message_Read_Receipt_Archive_Retention_Days_Description": "Número de dias para manter as confirmações de leitura no armazenamento ativo antes de arquivar no armazenamento frio", "Message_Read_Receipt_Archive_Cron": "Agendamento do Arquivo", diff --git a/packages/i18n/src/locales/pt.i18n.json b/packages/i18n/src/locales/pt.i18n.json index a4472b0d91f6d..47df16457cdab 100644 --- a/packages/i18n/src/locales/pt.i18n.json +++ b/packages/i18n/src/locales/pt.i18n.json @@ -1701,6 +1701,9 @@ "Message_Read_Receipt_Enabled": "Mostrar Recibos de Leitura", "Message_Read_Receipt_Store_Users": "Recibos de leitura detalhados", "Message_Read_Receipt_Store_Users_Description": "Mostra os recibos de leitura de cada utilizador ", + "Message_Read_Receipt_Archive_Enabled": "Ativar Arquivo de Recibos de Leitura", + "Message_Read_Receipt_Archive_Enabled_Description": "Quando ativado, os recibos de leitura mais antigos que o período de retenção serão movidos do armazenamento ativo para o armazenamento frio.", + "Message_Read_Receipt_Archive_Enabled_Alert": "Atenção: Ativar esta opção num workspace com grande volume de recibos de leitura pode fazer com que o sistema processe a migração para o armazenamento frio por um período muito prolongado, afetando o desempenho em produção. É aconselhável limpar os dados de recibos de leitura ou migrá-los manualmente antes de ativar esta configuração.", "Message_Read_Receipt_Archive_Retention_Days": "Dias de Retenção no Arquivo", "Message_Read_Receipt_Archive_Retention_Days_Description": "Número de dias para manter os recibos de leitura no armazenamento ativo antes de arquivar no armazenamento frio", "Message_Read_Receipt_Archive_Cron": "Agendamento do Arquivo",