diff --git a/.changeset/tender-seas-sparkle.md b/.changeset/tender-seas-sparkle.md new file mode 100644 index 0000000000000..9c9819c8c3a39 --- /dev/null +++ b/.changeset/tender-seas-sparkle.md @@ -0,0 +1,13 @@ +--- +'@rocket.chat/meteor': minor +--- + +Introduces Cold Storage Archiving for Read Receipts to improve performance and scalability in large deployments. + +Enterprise workspaces can now archive older read receipts into a dedicated cold storage collection, reducing the size of the primary read receipts dataset and improving query performance in environments with high message volumes. + +This feature is disabled by default and can be enabled through the new setting: + +**Message → Read Receipts → Enable Read Receipts Cold Storage** + +This feature is especially recommended for deployments with high message throughput and long data retention requirements, where reducing the size of hot collections significantly improves overall system responsiveness. diff --git a/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts b/apps/meteor/app/lib/server/functions/cleanRoomHistory.ts index 2bea0914ee00f..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, 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,9 @@ export async function cleanRoomHistory({ selectedMessageIds, ); - if (!limit) { - const uids = await Users.findByUsernames(fromUsers, { projection: { _id: 1 } }) - .map((user) => user._id) - .toArray(); - await ReadReceipts.removeByIdPinnedTimestampLimitAndUsers(rid, excludePinned, ignoreDiscussion, ts, uids, ignoreThreads); - } else if (selectedMessageIds) { + if (limit && selectedMessageIds) { await ReadReceipts.removeByMessageIds(selectedMessageIds); + await ReadReceiptsArchive.removeByMessageIds(selectedMessageIds); } 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..90755eb5bf002 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'; @@ -293,6 +294,7 @@ export async function removeOmnichannelRoom(rid: string) { }), LivechatInquiry.removeByRoomId(rid), LivechatRooms.removeById(rid), + ReadReceiptsArchive.removeByRoomId(rid), ]); if (result[3]?.status === 'fulfilled' && result[3].value?.deletedCount && inquiry) { diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 14de6d696f9b4..58f295ec02991 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 a32d591ad238f..4c5db1ddf70b5 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -1,6 +1,6 @@ import type { IMessage, IRoom, IUser } 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 { callbacks } from '../../../server/lib/callbacks'; import { @@ -41,10 +41,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 7fafc301275e7..2edb7cef27038 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'; @@ -107,7 +97,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/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/ee/server/cron/readReceiptsArchive.spec.ts b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts new file mode 100644 index 0000000000000..b42bf7a0d90e6 --- /dev/null +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.spec.ts @@ -0,0 +1,205 @@ +import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; + +import { archiveOldReadReceipts } from './readReceiptsArchive'; +import { settings } from '../../../app/settings/server'; + +jest.mock('@rocket.chat/models', () => ({ + ReadReceipts: { + findOlderThan: jest.fn(), + removeByIds: jest.fn(), + }, + ReadReceiptsArchive: { + insertMany: jest.fn(), + }, + Messages: { + setReceiptsArchivedById: 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(), + }, +})); + +jest.mock('@rocket.chat/cron', () => ({ + cronJobs: { + add: jest.fn(), + has: jest.fn(), + remove: jest.fn(), + }, +})); + +// Mock setTimeout to avoid actual delays in tests +jest.useFakeTimers(); + +describe('Read Receipts Archive', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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 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 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 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); + }); + + it('should not process when no old receipts found', async () => { + (settings.get as jest.Mock).mockReturnValue(30); + + const limitMock = jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]) }); + (ReadReceipts.findOlderThan as jest.Mock).mockReturnValue({ limit: limitMock }); + + await archiveOldReadReceipts(); + + expect(ReadReceiptsArchive.insertMany).not.toHaveBeenCalled(); + expect(Messages.setReceiptsArchivedById).not.toHaveBeenCalled(); + expect(ReadReceipts.removeByIds).not.toHaveBeenCalled(); + }); + + it('should archive old receipts in single batch 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 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.setReceiptsArchivedById as jest.Mock).mockResolvedValue({ modifiedCount: 2 }); + (ReadReceipts.removeByIds 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.setReceiptsArchivedById).toHaveBeenCalledWith(['msg1', 'msg2'], true); + + // Verify old receipts were deleted by ID + expect(ReadReceipts.removeByIds).toHaveBeenCalledWith(['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.setReceiptsArchivedById as jest.Mock).mockResolvedValue({ modifiedCount: 1 }); + (ReadReceipts.removeByIds 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.setReceiptsArchivedById).toHaveBeenCalledTimes(2); + expect(ReadReceipts.removeByIds).toHaveBeenCalledTimes(2); + }); + + 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 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'), { + code: 11000, + result: { insertedCount: 0 }, + }); + (ReadReceiptsArchive.insertMany as jest.Mock).mockRejectedValue(duplicateError); + (Messages.setReceiptsArchivedById as jest.Mock).mockResolvedValue({ modifiedCount: 1 }); + (ReadReceipts.removeByIds as jest.Mock).mockResolvedValue({ deletedCount: 1 }); + + await archiveOldReadReceipts(); + + // Should continue despite duplicate error + expect(Messages.setReceiptsArchivedById).toHaveBeenCalled(); + expect(ReadReceipts.removeByIds).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 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'); + (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 new file mode 100644 index 0000000000000..90a55bfc42363 --- /dev/null +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -0,0 +1,118 @@ +import { cronJobs } from '@rocket.chat/cron'; +import { Logger } from '@rocket.chat/logger'; +import { ReadReceipts, ReadReceiptsArchive, Messages } from '@rocket.chat/models'; + +import { settings } from '../../../app/settings/server'; +import { sleep } from '../../../lib/utils/sleep'; + +const logger = new Logger('ReadReceiptsArchive'); + +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; +const BATCH_DELAY_MS = 1000; // 1 second delay between batches + +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({ msg: 'Starting to archive old read receipts', batchSize, cutoffDate }); + + let totalProcessed = 0; + let batchNumber = 0; + let hasMore = true; + + // eslint-disable-next-line no-await-in-loop + while (hasMore) { + batchNumber++; + logger.info({ msg: '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) { + logger.info({ msg: 'No more read receipts to archive', totalProcessed }); + break; + } + + logger.info({ msg: 'Found read receipts in batch', count: oldReceipts.length, batchNumber }); + + // 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) + try { + // eslint-disable-next-line no-await-in-loop + await ReadReceiptsArchive.insertMany(oldReceipts, { ordered: false }); + logger.info({ msg: 'Successfully archived read receipts', count: oldReceipts.length, 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 + if (error && typeof error === 'object' && ('code' in error || 'writeErrors' in error)) { + const mongoError = error as { + code?: number; + result?: { insertedCount?: number }; + writeErrors?: Array<{ code?: number }>; + }; + const onlyDuplicateErrors = mongoError.writeErrors?.length + ? mongoError.writeErrors.every((writeError) => writeError.code === 11000) + : mongoError.code === 11000; + + if (onlyDuplicateErrors) { + const insertedCount = mongoError.result?.insertedCount || 0; + logger.info({ msg: 'Archived read receipts (some were already archived)', insertedCount, batchNumber }); + } else { + throw error; + } + } else { + throw error; + } + } + + // Mark messages as having archived receipts + // eslint-disable-next-line no-await-in-loop + const updateResult = await Messages.setReceiptsArchivedById(messageIds, true); + logger.info({ msg: 'Marked messages as having archived receipts', modifiedCount: updateResult.modifiedCount, 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.removeByIds(receiptIds); + logger.info({ msg: 'Deleted old receipts from hot storage', deletedCount: deleteResult.deletedCount, batchNumber }); + + totalProcessed += oldReceipts.length; + + // If we processed a full batch, there might be more, so wait and continue + if (oldReceipts.length === batchSize) { + logger.info({ msg: 'Batch complete, waiting before next batch', batchNumber, delayMs: BATCH_DELAY_MS }); + // eslint-disable-next-line no-await-in-loop + await sleep(BATCH_DELAY_MS); + } else { + // This was the last batch (partial batch) + logger.info({ msg: 'Final batch complete', batchNumber, totalProcessed }); + hasMore = false; + } + } catch (error) { + logger.error({ msg: 'Error during read receipts archiving', batchNumber, err: 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'); + } + + if (!settings.get('Message_Read_Receipt_Archive_Enabled')) { + return; + } + + logger.info({ msg: 'Scheduling read receipts archive cron job', cronSchedule }); + + return cronJobs.add('ReadReceiptsArchive', cronSchedule, 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 62122cab7c3ed..8a04d7e9a0892 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, 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,21 +114,15 @@ class ReadReceiptClass { getMessages: () => Promise[]>, roomId: string, userId: string, - extraData: Partial = {}, ) { 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, 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) { @@ -147,15 +137,30 @@ 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 + let coldReceipts: IReadReceipt[] = []; + if (message.receiptsArchived) { + coldReceipts = await ReadReceiptsArchive.findByMessageId(message._id).toArray(); + } + + // Combine receipts from both storages + const receipts = [...new Map([...hotReceipts, ...coldReceipts].map((receipt) => [receipt._id, receipt])).values()]; + + // 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/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..10851e2e9fc9d 100644 --- a/apps/meteor/ee/server/models/raw/ReadReceipts.ts +++ b/apps/meteor/ee/server/models/raw/ReadReceipts.ts @@ -1,15 +1,23 @@ -import type { IUser, IMessage, IReadReceipt, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IReadReceipt } 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 { FindCursor, Db, IndexDescription, DeleteResult } from 'mongodb'; export class ReadReceiptsRaw extends BaseRaw implements IReadReceiptsModel { - constructor(db: Db, trash?: Collection>) { - super(db, 'read_receipts', trash); + constructor(db: Db) { + super(db, 'read_receipts'); } protected override modelIndexes(): IndexDescription[] { - return [{ key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, { key: { messageId: 1 } }, { key: { userId: 1 } }]; + return [ + // Unique index removed to increase performance. Uniqueness in now handled via composite value of _id + // TODO: Drop existent index on database migration of 9.0 + // { key: { roomId: 1, userId: 1, messageId: 1 }, unique: true }, + { key: { messageId: 1 } }, + { key: { userId: 1 } }, + { key: { roomId: 1 } }, + { key: { ts: -1 } }, + ]; } findByMessageId(messageId: string): FindCursor { @@ -36,43 +44,7 @@ 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 new file mode 100644 index 0000000000000..59380dca50ff5 --- /dev/null +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -0,0 +1,46 @@ +import type { IReadReceipt } from '@rocket.chat/core-typings'; +import type { IReadReceiptsModel } from '@rocket.chat/model-typings'; +import { BaseRaw, readSecondaryPreferred } from '@rocket.chat/models'; +import type { FindCursor, Db, IndexDescription, DeleteResult } from 'mongodb'; + +export class ReadReceiptsArchiveRaw extends BaseRaw implements IReadReceiptsModel { + constructor(db: Db) { + super(db, 'read_receipts_archive'); + } + + protected override modelIndexes(): IndexDescription[] { + return [{ key: { messageId: 1 } }, { key: { userId: 1 } }, { key: { roomId: 1 } }, { key: { ts: -1 } }]; + } + + findByMessageId(messageId: string): FindCursor { + // Pass read preference directly to the find query to prefer reading from secondary replicas + return this.find({ messageId }, { readPreference: readSecondaryPreferred() }); + } + + // Archive doesn't need all the delete methods from hot storage + // But we implement them to satisfy the interface + removeByUserId(userId: string): Promise { + return this.deleteMany({ userId }); + } + + removeByRoomId(roomId: string): Promise { + return this.deleteMany({ roomId }); + } + + removeByRoomIds(roomIds: string[]): Promise { + return this.deleteMany({ roomId: { $in: roomIds } }); + } + + removeByMessageId(messageId: string): Promise { + return this.deleteMany({ messageId }); + } + + removeByMessageIds(messageIds: string[]): Promise { + return this.deleteMany({ messageId: { $in: messageIds } }); + } + + 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/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..6595410fd5e2c --- /dev/null +++ b/apps/meteor/ee/server/startup/readReceiptsArchive.ts @@ -0,0 +1,7 @@ +import { settings } from '../../../app/settings/server'; +import { readReceiptsArchiveCron } from '../cron/readReceiptsArchive'; + +// Watch for settings changes and update the cron schedule +settings.watchMultiple(['Message_Read_Receipt_Archive_Cron', 'Message_Read_Receipt_Archive_Enabled'], async () => { + await readReceiptsArchiveCron(); +}); diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index cb6261662258a..1c2439c4c7837 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', 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/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/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts index 85a829a6f06e1..734d3831eebef 100644 --- a/apps/meteor/server/settings/message.ts +++ b/apps/meteor/server/settings/message.ts @@ -65,6 +65,48 @@ export const createMessageSettings = () => public: true, enableQuery: { _id: 'Message_Read_Receipt_Enabled', value: true }, }); + await this.add('Message_Read_Receipt_Archive_Enabled', false, { + type: 'boolean', + enterprise: true, + 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, { + 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 }, + { _id: 'Message_Read_Receipt_Archive_Enabled', 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 }, + { _id: 'Message_Read_Receipt_Archive_Enabled', 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 }, + { _id: 'Message_Read_Receipt_Archive_Enabled', value: true }, + ], + }); }); await this.add('Message_CustomDomain_AutoLink', '', { type: 'string', diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 94dc60aa1488b..7a59a5b1702c8 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -239,6 +239,9 @@ export interface IMessage extends IRocketChatRecord { customFields?: Record; content?: EncryptedContent; + + // Read receipts migration flag + receiptsArchived?: boolean; } export type EncryptedMessageContent = Required>; 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 { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index eb71374580bd1..2abb096ac43b2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3522,6 +3522,15 @@ "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", + "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 492dccb57b4b6..5601b532c4ce6 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -3275,6 +3275,15 @@ "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", + "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 0b2e4049b16b5..0c55b23c355ca 100644 --- a/packages/i18n/src/locales/pt.i18n.json +++ b/packages/i18n/src/locales/pt.i18n.json @@ -1691,6 +1691,15 @@ "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", + "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", diff --git a/packages/model-typings/src/models/IMessagesModel.ts b/packages/model-typings/src/models/IMessagesModel.ts index 707f49b1937b6..0dd38fde30fc7 100644 --- a/packages/model-typings/src/models/IMessagesModel.ts +++ b/packages/model-typings/src/models/IMessagesModel.ts @@ -249,6 +249,7 @@ export interface IMessagesModel extends IBaseModel { newMessage: string, ): Promise; unlinkUserId(userId: string, newUserId: string, newUsername: string, newNameAlias: string): Promise; + setReceiptsArchivedById(ids: string[], archived: boolean): Promise; setSlackBotIdAndSlackTs(_id: string, slackBotId: string, slackTs: Date): Promise; setMessageAttachments(_id: string, attachments: IMessage['attachments']): Promise; diff --git a/packages/model-typings/src/models/IReadReceiptsModel.ts b/packages/model-typings/src/models/IReadReceiptsModel.ts index fb097619f3d7c..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,14 +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 3d644d527a058..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,22 +33,7 @@ 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({}); } } diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 4805f237ef585..099420cd2f7ae 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -122,6 +122,7 @@ export * from './helpers'; export { registerModel } from './proxify'; export { type Updater, UpdaterImpl } from './updater'; +export { readSecondaryPreferred } from './readSecondaryPreferred'; export const Apps = proxify('IAppsModel'); export const AppsPersistence = proxify('IAppsPersistenceModel'); @@ -177,6 +178,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'); diff --git a/packages/models/src/models/Messages.ts b/packages/models/src/models/Messages.ts index e16cb35fd41ee..81e43a5d1fe54 100644 --- a/packages/models/src/models/Messages.ts +++ b/packages/models/src/models/Messages.ts @@ -1381,6 +1381,19 @@ export class MessagesRaw extends BaseRaw implements IMessagesModel { return this.updateMany(query, update); } + setReceiptsArchivedById(ids: string[], archived: boolean): Promise { + return this.updateMany( + { + _id: { $in: ids }, + }, + { + $set: { + receiptsArchived: archived, + }, + }, + ); + } + // INSERT async createWithTypeRoomIdMessageUserAndUnread(