diff --git a/packages/data-schemas/src/methods/chatProject.spec.ts b/packages/data-schemas/src/methods/chatProject.spec.ts index 53f283a5a0..f21a075665 100644 --- a/packages/data-schemas/src/methods/chatProject.spec.ts +++ b/packages/data-schemas/src/methods/chatProject.spec.ts @@ -2,7 +2,7 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import { RetentionMode } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import type { IChatProject, IConversation, IMessage, ISharedLink } from '~/types'; +import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types'; import { createChatProjectMethods, type ChatProjectMethods } from './chatProject'; import { createModels } from '~/models'; @@ -23,6 +23,7 @@ let ChatProject: mongoose.Model; let Conversation: mongoose.Model; let Message: mongoose.Model; let SharedLink: mongoose.Model; +let File: mongoose.Model; let methods: ChatProjectMethods; let modelsToCleanup: string[] = []; @@ -38,6 +39,7 @@ beforeAll(async () => { Conversation = mongoose.models.Conversation as mongoose.Model; Message = mongoose.models.Message as mongoose.Model; SharedLink = mongoose.models.SharedLink as mongoose.Model; + File = mongoose.models.File as mongoose.Model; methods = createChatProjectMethods(mongoose); await mongoose.connect(mongoUri); @@ -59,6 +61,7 @@ afterEach(async () => { await Conversation.deleteMany({}); await Message.deleteMany({}); await SharedLink.deleteMany({}); + await File.deleteMany({}); }); async function createConversation(user: string, conversationId: string, title: string) { @@ -304,7 +307,7 @@ describe('ChatProject methods', () => { expect(deleteResult.deletedCount).toBe(0); }); - it('forces ephemeral retention on a permanent chat, its messages, and shares when assigning', async () => { + it('forces ephemeral retention on a permanent chat, its messages, shares, and files when assigning', async () => { const project = await methods.createChatProject(user, { name: 'Ephemeral' }); await createConversation(user, 'convo-1', 'Permanent'); await Message.create([ @@ -312,6 +315,13 @@ describe('ChatProject methods', () => { { messageId: uuidv4(), conversationId: 'convo-1', user, text: 'second' }, ]); await SharedLink.create({ conversationId: 'convo-1', user, shareId: uuidv4() }); + const fileId = uuidv4(); + await File.collection.insertOne({ + file_id: fileId, + conversationId: 'convo-1', + user: new mongoose.Types.ObjectId(), + expiredAt: null, + }); const result = await methods.assignConversationToProject( user, @@ -340,6 +350,9 @@ describe('ChatProject methods', () => { const share = await SharedLink.findOne({ user, conversationId: 'convo-1' }).lean(); expect(share?.expiredAt).toBeInstanceOf(Date); + + const file = await File.findOne({ file_id: fileId }).lean(); + expect(file?.expiredAt).toBeInstanceOf(Date); }); it('forces ephemeral retention when removing a chat from its project', async () => { @@ -380,6 +393,13 @@ describe('ChatProject methods', () => { await methods.assignConversationToProject(user, 'convo-1', projectId); await methods.assignConversationToProject(user, 'convo-2', projectId); await Message.create({ messageId: uuidv4(), conversationId: 'convo-1', user, text: 'hi' }); + const fileId = uuidv4(); + await File.collection.insertOne({ + file_id: fileId, + conversationId: 'convo-1', + user: new mongoose.Types.ObjectId(), + expiredAt: null, + }); await methods.deleteChatProject(user, projectId, ephemeralConfig); @@ -397,5 +417,8 @@ describe('ChatProject methods', () => { const message = await Message.findOne({ user, conversationId: 'convo-1' }).lean(); expect(message?.isTemporary).toBe(true); expect(message?.expiredAt).toBeInstanceOf(Date); + + const file = await File.findOne({ file_id: fileId }).lean(); + expect(file?.expiredAt).toBeInstanceOf(Date); }); }); diff --git a/packages/data-schemas/src/methods/chatProject.ts b/packages/data-schemas/src/methods/chatProject.ts index 45772512d7..d2a98036c7 100644 --- a/packages/data-schemas/src/methods/chatProject.ts +++ b/packages/data-schemas/src/methods/chatProject.ts @@ -6,6 +6,7 @@ import type { IChatProjectDocument, IConversation, IMessage, + IMongoFile, ISharedLink, } from '~/types'; import { @@ -287,10 +288,12 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C const Conversation = mongoose.models.Conversation as Model; const Message = mongoose.models.Message as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; return cascadeForcedRetentionByProject( Conversation, Message, SharedLink, + File, user, chatProjectId, resolveForcedRetentionDate(interfaceConfig), @@ -308,10 +311,12 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C const Conversation = mongoose.models.Conversation as Model; const Message = mongoose.models.Message as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; return cascadeForcedConversationRetention( Conversation, Message, SharedLink, + File, user, conversationId, resolveForcedRetentionDate(interfaceConfig), diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index c666b818cc..f2ec44b0df 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -1,6 +1,6 @@ import { RetentionMode, isForcedTemporaryRetention } from 'librechat-data-provider'; import type { DeleteResult, FilterQuery, Model } from 'mongoose'; -import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types'; +import type { AppConfig, IConversation, IMessage, IMongoFile, ISharedLink } from '~/types'; import { capForcedRetentionExpiry, capForcedRetentionToParent, @@ -213,10 +213,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa if (isForcedRetention && cascadeExpiredAt instanceof Date) { const Conversation = mongoose.models.Conversation as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; await cascadeForcedConversationRetention( Conversation, Message, SharedLink, + File, userId, conversationId, cascadeExpiredAt, @@ -409,6 +411,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa const Message = mongoose.models.Message as Model; const Conversation = mongoose.models.Conversation as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; if (metadata?.capExpiryToConversation === true) { forcedExpiredAt = await capForcedRetentionToParent( @@ -435,6 +438,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa Conversation, Message, SharedLink, + File, userId, conversationId, forcedExpiredAt, @@ -475,11 +479,13 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa const Message = mongoose.models.Message as Model; const Conversation = mongoose.models.Conversation as Model; const SharedLink = mongoose.models.SharedLink as Model; + const File = mongoose.models.File as Model; await cascadeForcedRetentionByTag( Conversation, Message, SharedLink, + File, userId, tag, forcedExpiredAt, diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index 946d6244f6..76d2562a5c 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -219,14 +219,15 @@ export const capForcedRetentionToParent = async ( /** * Converts or re-caps a parent conversation to the forced deadline and, when that first - * brings the conversation into the forced window, backfills its lagging messages. Shared - * by every forced-retention message-write path so a single conversation/message rule is - * enforced regardless of which save touched the chat. + * brings the conversation into the forced window, backfills its lagging messages, shares, and + * files. Shared by every forced-retention message-write path so a single conversation/message + * rule is enforced regardless of which save touched the chat. */ export const cascadeForcedConversationRetention = async ( Conversation: Model, Message: Model, SharedLink: Model, + File: Model, userId: string, conversationId: string, forcedExpiredAt: Date, @@ -243,6 +244,7 @@ export const cascadeForcedConversationRetention = async ( if (convoResult.modifiedCount > 0) { await forceConversationMessagesTemporary(Message, userId, conversationId, expiredAt); await capConversationSharedLinks(SharedLink, userId, conversationId, expiredAt); + await capConversationFiles(File, conversationId, expiredAt); } }; @@ -252,13 +254,14 @@ export const cascadeForcedConversationRetention = async ( * (`Conversation.updateMany`) without setting `isTemporary`/`expiredAt` would otherwise leave a * permanent chat visible and non-expiring after an install switched to ephemeral. Chats are * bucketed by their capped deadline so each bucket converts the chats, backfills their messages, - * and caps their shares in one pass; the gap filter keeps it a no-op for chats that already - * conform and never extends a chat that already expires sooner. + * and caps their shares and files in one pass; the gap filter keeps it a no-op for chats that + * already conform and never extends a chat that already expires sooner. */ const cascadeForcedRetentionForConversationSet = async ( Conversation: Model, Message: Model, SharedLink: Model, + File: Model, userId: string, conversationMatch: FilterQuery, forcedExpiredAt: Date, @@ -319,6 +322,13 @@ const cascadeForcedRetentionForConversationSet = async ( }, { $set: { expiredAt } }, ); + await File.updateMany( + { + conversationId: { $in: conversationIds }, + $or: [{ expiredAt: null }, { expiredAt: { $gt: expiredAt } }], + }, + { $set: { expiredAt } }, + ); } }; @@ -332,6 +342,7 @@ export const cascadeForcedRetentionByTag = ( Conversation: Model, Message: Model, SharedLink: Model, + File: Model, userId: string, tag: string, forcedExpiredAt: Date, @@ -340,6 +351,7 @@ export const cascadeForcedRetentionByTag = ( Conversation, Message, SharedLink, + File, userId, { tags: tag } as FilterQuery, forcedExpiredAt, @@ -355,6 +367,7 @@ export const cascadeForcedRetentionByProject = ( Conversation: Model, Message: Model, SharedLink: Model, + File: Model, userId: string, chatProjectId: string, forcedExpiredAt: Date, @@ -363,6 +376,7 @@ export const cascadeForcedRetentionByProject = ( Conversation, Message, SharedLink, + File, userId, { chatProjectId } as FilterQuery, forcedExpiredAt,