diff --git a/packages/data-schemas/src/methods/chatProject.ts b/packages/data-schemas/src/methods/chatProject.ts index d2a98036c7..e38f1587f7 100644 --- a/packages/data-schemas/src/methods/chatProject.ts +++ b/packages/data-schemas/src/methods/chatProject.ts @@ -300,19 +300,19 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C ); } - function forceConversationRetention( + async function forceConversationRetention( user: string, conversationId: string, interfaceConfig?: AppConfig['interfaceConfig'], ): Promise { if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) { - return Promise.resolve(); + return; } 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( + await cascadeForcedConversationRetention( Conversation, Message, SharedLink, diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 4d6497e2ed..87b1dcf1a2 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -1200,6 +1200,41 @@ describe('Message Operations', () => { expect(refreshed?.lastConversationId ?? null).toBeNull(); }); + it('refreshes owning project stats when a message-only save converts 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 saveMessage( + { + userId: 'user123', + interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL }, + }, + { messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' }, + { context: 'branch', capExpiryToConversation: true }, + ); + + 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 884061b0c4..cbbe4f59bb 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -215,7 +215,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa const Conversation = mongoose.models.Conversation as Model; const SharedLink = mongoose.models.SharedLink as Model; const File = mongoose.models.File as Model; - await cascadeForcedConversationRetention( + const convertedParent = await cascadeForcedConversationRetention( Conversation, Message, SharedLink, @@ -224,6 +224,16 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa conversationId, cascadeExpiredAt, ); + /** + * Message-only writes (branch/artifact/abort saves) have no `saveConvo` afterward to + * recompute project stats, so when the cascade just hid a project chat the cached + * count/lastConversationId must be refreshed here. + */ + if (convertedParent) { + await refreshForcedRetentionProjectStats(userId, { + conversationId, + } as FilterQuery); + } } return message.toObject(); @@ -467,7 +477,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa }, ]); } - await cascadeForcedConversationRetention( + const convertedParent = await cascadeForcedConversationRetention( Conversation, Message, SharedLink, @@ -476,9 +486,11 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa conversationId, forcedExpiredAt, ); - await refreshForcedRetentionProjectStats(userId, { - conversationId, - } as FilterQuery); + if (convertedParent) { + await refreshForcedRetentionProjectStats(userId, { + conversationId, + } as FilterQuery); + } } /** diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts index 81487c2df3..1f764a4fc1 100644 --- a/packages/data-schemas/src/utils/retention.ts +++ b/packages/data-schemas/src/utils/retention.ts @@ -224,7 +224,9 @@ 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, 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. + * rule is enforced regardless of which save touched the chat. Returns whether the parent row + * was converted, so callers can refresh caches (e.g. project stats) that a visibility flip + * invalidates. */ export const cascadeForcedConversationRetention = async ( Conversation: Model, @@ -234,13 +236,13 @@ export const cascadeForcedConversationRetention = async ( userId: string, conversationId: string, forcedExpiredAt: Date, -): Promise => { +): Promise => { const parent = await Conversation.findOne( { conversationId, user: userId }, 'isTemporary expiredAt', ).lean(); if (parent == null) { - return; + return false; } const expiredAt = capForcedRetentionExpiry(parent.expiredAt, forcedExpiredAt); /** @@ -255,12 +257,13 @@ export const cascadeForcedConversationRetention = async ( await capConversationSharedLinks(SharedLink, userId, conversationId, expiredAt); await capConversationFiles(File, conversationId, expiredAt); if (!conversationNeedsForcedRetention(parent, expiredAt)) { - return; + return false; } - await Conversation.updateOne( + const convoResult = await Conversation.updateOne( { conversationId, user: userId, ...forcedRetentionGapFilter(expiredAt) }, { $set: { isTemporary: true, expiredAt } }, ); + return (convoResult.modifiedCount ?? 0) > 0; }; /**