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..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/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/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..24d64d03470fe --- /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(), + 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(), + }, +})); + +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.updateMany).not.toHaveBeenCalled(); + expect(ReadReceipts.deleteMany).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.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 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 () => { + (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.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 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..3500b2eab7ae8 --- /dev/null +++ b/apps/meteor/ee/server/cron/readReceiptsArchive.ts @@ -0,0 +1,113 @@ +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'; + +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()}, batch size: ${batchSize}`); + + let totalProcessed = 0; + let batchNumber = 0; + let hasMore = 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) { + logger.info(`No more read receipts to archive. Total processed: ${totalProcessed}`); + break; + } + + 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))]; + + 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) { + // 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 in batch ${batchNumber} (some were already archived)`); + } else { + throw error; + } + } else { + throw error; + } + } + + // 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}`); + + totalProcessed += oldReceipts.length; + + // 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}`); + hasMore = false; + } + } catch (error) { + logger.error(`Error during read receipts archiving in batch ${batchNumber}: ${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(`Scheduling read receipts archive cron job with schedule: ${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 8be6bc78ab1b8..6f922711c3621 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 = [...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/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..43e029d3515a7 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>) { @@ -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 { @@ -36,43 +36,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..2058056106f38 --- /dev/null +++ b/apps/meteor/ee/server/models/raw/ReadReceiptsArchive.ts @@ -0,0 +1,46 @@ +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, DeleteResult } 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: { 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..7d7e21adbb028 --- /dev/null +++ b/apps/meteor/ee/server/startup/readReceiptsArchive.ts @@ -0,0 +1,16 @@ +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(); + } +}); + +settings.watch('Message_Read_Receipt_Archive_Enabled', async () => { + await readReceiptsArchiveCron(); +}); 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', 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..5e63b9429239c 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', true, { + 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/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'; 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/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 c465511523212..9abb0050dbdc3 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3480,6 +3480,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 bb0fcd949a94c..cf7a1ea59d17c 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -3286,6 +3286,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 d77fa3919e2d3..47df16457cdab 100644 --- a/packages/i18n/src/locales/pt.i18n.json +++ b/packages/i18n/src/locales/pt.i18n.json @@ -1701,6 +1701,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/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 9ae91adf616bc..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'); @@ -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');