diff --git a/api/server/routes/tags.js b/api/server/routes/tags.js index 3e78272985..10be2b2e8f 100644 --- a/api/server/routes/tags.js +++ b/api/server/routes/tags.js @@ -9,6 +9,7 @@ const { deleteConversationTag, getConversationTags, applyForcedRetention, + applyForcedRetentionToTag, getRoleByName, } = require('~/models'); const { requireJwtAuth, configMiddleware } = require('~/server/middleware'); @@ -35,6 +36,18 @@ const enforceForcedRetention = (req, conversationId, context) => { context }, ); +/** + * Enforces forced (ephemeral) retention on every conversation carrying a tag, for global + * tag renames/deletes that rewrite conversation rows without converting them; a no-op + * outside forced retention. + */ +const enforceForcedRetentionForTag = (req, tag, context) => + applyForcedRetentionToTag( + { userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig }, + { tag }, + { context }, + ); + /** * GET / * Retrieves all conversation tags for the authenticated user. @@ -80,11 +93,12 @@ router.post('/', configMiddleware, async (req, res) => { * @param {Object} req - Express request object * @param {Object} res - Express response object */ -router.put('/:tag', async (req, res) => { +router.put('/:tag', configMiddleware, async (req, res) => { try { const decodedTag = decodeURIComponent(req.params.tag); const tag = await updateConversationTag(req.user.id, decodedTag, req.body); if (tag) { + await enforceForcedRetentionForTag(req, req.body?.tag || decodedTag, 'PUT /api/tags/:tag'); res.status(200).json(tag); } else { res.status(404).json({ error: 'Tag not found' }); @@ -101,9 +115,10 @@ router.put('/:tag', async (req, res) => { * @param {Object} req - Express request object * @param {Object} res - Express response object */ -router.delete('/:tag', async (req, res) => { +router.delete('/:tag', configMiddleware, async (req, res) => { try { const decodedTag = decodeURIComponent(req.params.tag); + await enforceForcedRetentionForTag(req, decodedTag, 'DELETE /api/tags/:tag'); const tag = await deleteConversationTag(req.user.id, decodedTag); if (tag) { res.status(200).json(tag); diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index a7fcf7f2ef..f69b47a742 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -23,6 +23,7 @@ let saveMessage: ReturnType['saveMessage']; let getMessages: ReturnType['getMessages']; let updateMessage: ReturnType['updateMessage']; let applyForcedRetention: ReturnType['applyForcedRetention']; +let applyForcedRetentionToTag: ReturnType['applyForcedRetentionToTag']; let deleteMessages: ReturnType['deleteMessages']; let bulkSaveMessages: ReturnType['bulkSaveMessages']; let updateMessageText: ReturnType['updateMessageText']; @@ -42,6 +43,7 @@ beforeAll(async () => { getMessages = methods.getMessages; updateMessage = methods.updateMessage; applyForcedRetention = methods.applyForcedRetention; + applyForcedRetentionToTag = methods.applyForcedRetentionToTag; deleteMessages = methods.deleteMessages; bulkSaveMessages = methods.bulkSaveMessages; updateMessageText = methods.updateMessageText; @@ -1149,6 +1151,101 @@ describe('Message Operations', () => { }); }); + describe('applyForcedRetentionToTag', () => { + const Conversation = () => mongoose.models.Conversation as mongoose.Model; + + beforeEach(async () => { + await Conversation().deleteMany({}); + }); + + it('converts every permanent conversation carrying the tag under ephemeral mode', async () => { + const taggedA = uuidv4(); + const taggedB = uuidv4(); + const untagged = uuidv4(); + await Conversation().create([ + { conversationId: taggedA, user: 'user123', endpoint: 'openAI', tags: ['work'] }, + { conversationId: taggedB, user: 'user123', endpoint: 'openAI', tags: ['work', 'urgent'] }, + { conversationId: untagged, user: 'user123', endpoint: 'openAI', tags: ['personal'] }, + ]); + await Message.create([ + { messageId: uuidv4(), conversationId: taggedA, user: 'user123', text: 'a' }, + { messageId: uuidv4(), conversationId: taggedB, user: 'user123', text: 'b' }, + { messageId: uuidv4(), conversationId: untagged, user: 'user123', text: 'c' }, + ]); + + await applyForcedRetentionToTag( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { tag: 'work' }, + { context: 'DELETE /api/tags/:tag' }, + ); + + for (const conversationId of [taggedA, taggedB]) { + const convo = await Conversation().findOne({ conversationId }).lean(); + expect(convo?.isTemporary).toBe(true); + expect(convo?.expiredAt).toBeInstanceOf(Date); + const messages = await getMessages({ conversationId, user: 'user123' }); + for (const message of messages) { + expect(message.isTemporary).toBe(true); + expect(message.expiredAt).toBeInstanceOf(Date); + } + } + + const untouched = await Conversation().findOne({ conversationId: untagged }).lean(); + expect(untouched?.isTemporary ?? null).not.toBe(true); + expect(untouched?.expiredAt ?? null).toBeNull(); + }); + + it('does not extend a tagged conversation that already expires sooner', async () => { + const conversationId = uuidv4(); + const soonerExpiry = new Date(Date.now() + 60 * 60 * 1000); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + tags: ['work'], + isTemporary: true, + expiredAt: soonerExpiry, + }); + + await applyForcedRetentionToTag( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { tag: 'work' }, + { context: 'PUT /api/tags/:tag' }, + ); + + const convo = await Conversation().findOne({ conversationId }).lean(); + expect(convo?.expiredAt?.getTime()).toBe(soonerExpiry.getTime()); + }); + + it('is a no-op outside forced retention', async () => { + const conversationId = uuidv4(); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + tags: ['work'], + }); + + await applyForcedRetentionToTag( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.ALL }, + }, + { tag: 'work' }, + { context: 'PUT /api/tags/:tag' }, + ); + + const convo = await Conversation().findOne({ conversationId }).lean(); + expect(convo?.expiredAt ?? null).toBeNull(); + }); + }); + describe('Message cursor pagination', () => { /** * Helper to create messages with specific timestamps diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index b99187c409..133b4cbcae 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -4,6 +4,7 @@ import type { AppConfig, IConversation, IMessage, ISharedLink } from '~/types'; import { capForcedRetentionToParent, cascadeForcedConversationRetention, + cascadeForcedRetentionByTag, createFallbackRetentionDate, } from '~/utils/retention'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; @@ -47,6 +48,11 @@ export interface MessageMethods { params: { conversationId: string; messageId?: string }, metadata?: { context?: string; capExpiryToConversation?: boolean }, ): Promise; + applyForcedRetentionToTag( + ctx: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] }, + params: { tag: string }, + metadata?: { context?: string }, + ): Promise; deleteMessagesSince( userId: string, params: { messageId: string; conversationId: string }, @@ -419,6 +425,45 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa ); } + /** + * Enforces forced (ephemeral) retention on every conversation carrying a bookmark tag, + * for tag-scoped writes that bypass `saveConvo`/`applyForcedRetention` — global tag renames + * and deletes that `Conversation.updateMany` the tag on/off existing chats. Without this an + * older permanent chat touched only by a tag change after an install switches to ephemeral + * would stay visible and never expire. A no-op outside forced retention. + */ + async function applyForcedRetentionToTag( + { userId, interfaceConfig }: { userId: string; interfaceConfig?: AppConfig['interfaceConfig'] }, + { tag }: { tag: string }, + metadata?: { context?: string }, + ): Promise { + if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { + return; + } + + let forcedExpiredAt: Date; + try { + forcedExpiredAt = createTempChatExpirationDate(interfaceConfig); + } catch (err) { + logger.error('Error creating temporary chat expiration date:', err); + logger.info(`---\`applyForcedRetentionToTag\` context: ${metadata?.context}`); + forcedExpiredAt = createFallbackRetentionDate(); + } + + const Message = mongoose.models.Message as Model; + const Conversation = mongoose.models.Conversation as Model; + const SharedLink = mongoose.models.SharedLink as Model; + + await cascadeForcedRetentionByTag( + Conversation, + Message, + SharedLink, + userId, + tag, + forcedExpiredAt, + ); + } + /** * Deletes messages in a conversation since a specific message. */ @@ -556,6 +601,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa updateMessageText, updateMessage, applyForcedRetention, + applyForcedRetentionToTag, deleteMessagesSince, getMessages, getMessage, diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index 804adfbffd..216f60781d 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -168,3 +168,53 @@ export const cascadeForcedConversationRetention = async ( await capConversationSharedLinks(SharedLink, userId, conversationId, forcedExpiredAt); } }; + +/** + * Bulk-applies forced retention to every conversation carrying a bookmark tag. A tag rename + * or delete writes conversation rows directly (`Conversation.updateMany`) without setting + * `isTemporary`/`expiredAt`, so a permanent chat tagged before the install switched to + * ephemeral would otherwise stay visible and never expire. One pass converts the chats, + * backfills their messages, and caps their shares; the gap filter keeps it a no-op for chats + * that already conform and never extends a chat that already expires sooner. + */ +export const cascadeForcedRetentionByTag = async ( + Conversation: Model, + Message: Model, + SharedLink: Model, + userId: string, + tag: string, + forcedExpiredAt: Date, +): Promise => { + const taggedConversations = await Conversation.find( + { user: userId, tags: tag }, + 'conversationId', + ).lean>(); + if (taggedConversations.length === 0) { + return; + } + const conversationIds = taggedConversations.map((convo) => convo.conversationId); + await Conversation.updateMany( + { + user: userId, + conversationId: { $in: conversationIds }, + ...forcedRetentionGapFilter(forcedExpiredAt), + }, + { $set: { isTemporary: true, expiredAt: forcedExpiredAt } }, + ); + await Message.updateMany( + { + user: userId, + conversationId: { $in: conversationIds }, + ...forcedRetentionGapFilter(forcedExpiredAt), + }, + { $set: { isTemporary: true, expiredAt: forcedExpiredAt } }, + ); + await SharedLink.updateMany( + { + user: userId, + conversationId: { $in: conversationIds }, + $or: [{ expiredAt: null }, { expiredAt: { $gt: forcedExpiredAt } }], + }, + { $set: { expiredAt: forcedExpiredAt } }, + ); +};