diff --git a/packages/api/src/files/retention.spec.ts b/packages/api/src/files/retention.spec.ts index b6b3ccc801..604a60d204 100644 --- a/packages/api/src/files/retention.spec.ts +++ b/packages/api/src/files/retention.spec.ts @@ -52,14 +52,41 @@ describe('retention helpers', () => { expect(dependencies.getConvo).not.toHaveBeenCalled(); }); - it('returns expiry when retentionMode is EPHEMERAL', async () => { + it('returns a fresh expiry when retentionMode is EPHEMERAL and the conversation has no earlier deadline', async () => { + dependencies.getConvo.mockResolvedValue(null); + + const result = await getRetentionExpiry( + request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }), + dependencies, + ); + + expect(result).toEqual({ expiredAt: expirationDate }); + expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1'); + }); + + it('caps an ephemeral file to a parent conversation that expires sooner', async () => { + const soonerExpiry = new Date('2029-01-01T00:00:00.000Z'); + dependencies.getConvo.mockResolvedValue({ expiredAt: soonerExpiry }); + + const result = await getRetentionExpiry( + request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }), + dependencies, + ); + + expect(result).toEqual({ expiredAt: soonerExpiry }); + }); + + it('keeps the fresh window when an ephemeral parent expires later', async () => { + dependencies.getConvo.mockResolvedValue({ + expiredAt: new Date('2031-01-01T00:00:00.000Z'), + }); + const result = await getRetentionExpiry( request({ config: { interfaceConfig: { retentionMode: RetentionMode.EPHEMERAL } } }), dependencies, ); expect(result).toEqual({ expiredAt: expirationDate }); - expect(dependencies.getConvo).not.toHaveBeenCalled(); }); it('returns a fresh expiry when the conversation has an active expiration', async () => { @@ -322,7 +349,7 @@ describe('retention helpers', () => { ); expect(result).toEqual({ expiredAt: expirationDate }); - expect(dependencies.getConvo).not.toHaveBeenCalled(); + expect(dependencies.getConvo).toHaveBeenCalledWith('user-1', 'convo-1'); expect(dependencies.createExpirationDate).toHaveBeenCalledTimes(1); }); diff --git a/packages/api/src/files/retention.ts b/packages/api/src/files/retention.ts index 7487b2f5a0..d4a74b6a28 100644 --- a/packages/api/src/files/retention.ts +++ b/packages/api/src/files/retention.ts @@ -99,11 +99,50 @@ const getRetentionCacheKey = (req: RetentionRequest): string => String(req.body?.isTemporary ?? ''), ].join('|'); +/** + * Resolves a forced (ephemeral) file deadline. The file is always retained, but capped to the + * minimum of a freshly created window and the parent conversation's active expiry, so a new + * attachment cannot linger in files/storage after the conversation and messages are TTL-deleted. + */ +async function computeForcedRetentionExpiry( + req: RetentionRequest | null | undefined, + dependencies: RetentionDependencies, +): Promise { + const fresh = createRetentionExpiry(req, dependencies); + const conversationId = req?.body?.conversationId; + const userId = req?.user?.id; + if (!conversationId || !userId) { + return fresh; + } + + try { + const convo = await dependencies.getConvo(userId, conversationId); + const conversationExpiredAt = getConversationExpirationDate(convo); + if (conversationExpiredAt == null) { + return fresh; + } + if (!isActiveExpirationDate(conversationExpiredAt)) { + return { expiredAt: conversationExpiredAt }; + } + if (fresh.expiredAt != null && conversationExpiredAt < fresh.expiredAt) { + return { expiredAt: conversationExpiredAt }; + } + return fresh; + } catch (err) { + dependencies.logger?.error('[getRetentionExpiry] Error checking conversation retention:', err); + return fresh; + } +} + async function computeRetentionExpiry( req: RetentionRequest | null | undefined, dependencies: RetentionDependencies, ): Promise { - if (isAllDataRetention(req?.config?.interfaceConfig?.retentionMode)) { + const retentionMode = req?.config?.interfaceConfig?.retentionMode; + if (retentionMode === RetentionMode.EPHEMERAL) { + return computeForcedRetentionExpiry(req, dependencies); + } + if (isAllDataRetention(retentionMode)) { return createRetentionExpiry(req, dependencies); } diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index e14fd1fee2..9f82adce53 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -837,6 +837,39 @@ describe('Message Operations', () => { } }); + it('preserves a carried-over message that already expires sooner than the forced window', async () => { + const conversationId = uuidv4(); + const soonerExpiry = new Date(Date.now() + 30 * 60 * 1000); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + title: 'Permanent chat with a sooner-expiring message', + isTemporary: false, + }); + const soonerMessageId = uuidv4(); + await Message.create({ + messageId: soonerMessageId, + conversationId, + user: 'user123', + text: 'all-mode message with its own sooner TTL', + isTemporary: false, + expiredAt: soonerExpiry, + }); + + await saveMessage( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { messageId: uuidv4(), conversationId, text: 'follow-up', user: 'user123' }, + ); + + const sooner = await Message.findOne({ messageId: soonerMessageId }).lean(); + expect(sooner?.isTemporary).toBe(true); + expect(sooner?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); + }); + it('re-caps an already temporary parent and its messages to a shorter ephemeral window', async () => { const conversationId = uuidv4(); const longerExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index 71aca59324..adc7961c2d 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -94,6 +94,11 @@ export const capForcedRetentionExpiry = ( * whose `expiredAt` is later than a newly shortened window — that would otherwise outlive * the converted conversation. The gap filter pulls all of them onto the ephemeral schedule * and stays a no-op once a conversation already conforms. + * + * Each message keeps its own earlier deadline: a carried-over message whose per-message TTL + * already expires sooner than the forced window is marked temporary but its `expiredAt` is + * left untouched (`$min`), so converting the conversation never extends data that was already + * scheduled to expire sooner. */ export const forceConversationMessagesTemporary = async ( Message: Model, @@ -103,7 +108,7 @@ export const forceConversationMessagesTemporary = async ( ): Promise => { await Message.updateMany( { conversationId, user: userId, ...forcedRetentionGapFilter(expiredAt) }, - { $set: { isTemporary: true, expiredAt } }, + [{ $set: { isTemporary: true, expiredAt: { $min: ['$expiredAt', expiredAt] } } }], ); }; @@ -245,7 +250,7 @@ export const cascadeForcedRetentionByTag = async ( conversationId: { $in: conversationIds }, ...forcedRetentionGapFilter(expiredAt), }, - { $set: { isTemporary: true, expiredAt } }, + [{ $set: { isTemporary: true, expiredAt: { $min: ['$expiredAt', expiredAt] } } }], ); await SharedLink.updateMany( {