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.
This commit is contained in:
Marco Beretta 2026-07-04 16:59:24 +02:00
parent 8bcfb0772c
commit e43364982d
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 74 additions and 1 deletions

View file

@ -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<IChatProject>;
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<IChatProject>();
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({

View file

@ -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<IConversation>,
): Promise<void> {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const projectChats = await Conversation.find(
{ user: userId, ...conversationFilter, chatProjectId: { $exists: true, $ne: null } },
'chatProjectId',
).lean<Array<{ chatProjectId?: string | null }>>();
if (projectChats.length === 0) {
return;
}
const projectIds = new Set<string>();
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<IConversation>);
}
/**
@ -490,6 +526,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
tag,
forcedExpiredAt,
);
await refreshForcedRetentionProjectStats(userId, { tags: tag } as FilterQuery<IConversation>);
}
/**