From e43364982dffb7615a8aaef367efbe70776c0901 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:59:24 +0200 Subject: [PATCH] fix: refresh project stats when forced retention hides a project chat applyForcedRetention (message edit/feedback/share/tag writes) converts a chat to isTemporary: true, which visibleProjectConversationFilter excludes, but unlike saveConvo it never recomputed the owning project's cached stats. A pre-existing project chat converted this way left the project's conversationCount and lastConversationId pointing at a chat the workspace no longer shows. After the cascade, recompute the stats of every touched chat's project (the single conversation for applyForcedRetention, all tagged chats for applyForcedRetentionToTag), matching saveConvo's retention-visibility refresh. Scoped to conversations carrying a chatProjectId, so it is a no-op for chats outside any project. --- .../data-schemas/src/methods/message.spec.ts | 38 ++++++++++++++++++- packages/data-schemas/src/methods/message.ts | 37 ++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index cd67cd8159..79e368658a 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.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 { IConversation, IMessage, IMongoFile, ISharedLink } from '..'; +import type { IChatProject, IConversation, IMessage, IMongoFile, ISharedLink } from '..'; import { tenantStorage, runAsSystem } from '~/config/tenantContext'; import { sweepForcedRetention } from '../utils/retention'; import { createMessageMethods } from './message'; @@ -1164,6 +1164,42 @@ describe('Message Operations', () => { expect(message?.expiredAt?.getTime()).toBe(messageDeadline.getTime()); }); + it('refreshes owning project stats when forced retention hides a project chat', async () => { + const ChatProject = mongoose.models.ChatProject as mongoose.Model; + await ChatProject.deleteMany({}); + const conversationId = uuidv4(); + const project = await ChatProject.create({ + name: 'Ephemeral Project', + user: 'user123', + conversationCount: 1, + lastConversationId: conversationId, + lastConversationAt: new Date(), + }); + const projectId = project._id!.toString(); + await Conversation().create({ + conversationId, + user: 'user123', + endpoint: 'openAI', + title: 'Project chat', + isTemporary: false, + chatProjectId: projectId, + }); + await Message.create({ messageId: uuidv4(), conversationId, user: 'user123', text: 'hi' }); + + await applyForcedRetention( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { conversationId }, + { context: 'tag' }, + ); + + const refreshed = await ChatProject.findById(projectId).lean(); + expect(refreshed?.conversationCount).toBe(0); + expect(refreshed?.lastConversationId ?? null).toBeNull(); + }); + it('converts a permanent conversation and its messages without a messageId (tag write)', async () => { const conversationId = uuidv4(); await Conversation().create({ diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index f2ec44b0df..884061b0c4 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -9,6 +9,7 @@ import { createFallbackRetentionDate, } from '~/utils/retention'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; +import { refreshChatProjectStatsForUser } from './chatProject'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; import logger from '~/config/winston'; @@ -377,6 +378,38 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa } } + /** + * Recomputes the owning project's cached stats after forced retention hides project chats. + * A conversion flips the conversation to `isTemporary: true`, which + * `visibleProjectConversationFilter` excludes, so a stale count/`lastConversationId` would keep + * pointing at a chat the project workspace no longer shows — matching `saveConvo`, which already + * refreshes project stats on a retention-visibility change. Scoped to conversations carrying a + * `chatProjectId`; a no-op when none of the touched chats belong to a project. + */ + async function refreshForcedRetentionProjectStats( + userId: string, + conversationFilter: FilterQuery, + ): Promise { + const Conversation = mongoose.models.Conversation as Model; + const projectChats = await Conversation.find( + { user: userId, ...conversationFilter, chatProjectId: { $exists: true, $ne: null } }, + 'chatProjectId', + ).lean>(); + if (projectChats.length === 0) { + return; + } + + const projectIds = new Set(); + for (const chat of projectChats) { + if (typeof chat.chatProjectId === 'string' && chat.chatProjectId.length > 0) { + projectIds.add(chat.chatProjectId); + } + } + for (const projectId of projectIds) { + await refreshChatProjectStatsForUser(mongoose, userId, projectId); + } + } + /** * Enforces forced (ephemeral) retention on a conversation (and optionally a specific * message) that was touched outside `saveMessage`/`saveConvo` — message edits, feedback, @@ -443,6 +476,9 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa conversationId, forcedExpiredAt, ); + await refreshForcedRetentionProjectStats(userId, { + conversationId, + } as FilterQuery); } /** @@ -490,6 +526,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa tag, forcedExpiredAt, ); + await refreshForcedRetentionProjectStats(userId, { tags: tag } as FilterQuery); } /**