From 79d75ad8fc3562f9d281f235b5a2ae460f27aaab Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:37:20 +0200 Subject: [PATCH] fix: refresh project stats when a message-only save hides a project chat saveMessage's forced-retention cascade converts an old project-assigned chat to isTemporary: true, but message-only writes (branch/artifact/abort saves) have no saveConvo afterward to recompute the project's cached stats, so the count and lastConversationId kept pointing at a chat the project view no longer shows. Make cascadeForcedConversationRetention report whether it converted the parent row and refresh the owning project's stats from saveMessage when it did. The applyForcedRetention refresh is gated on the same signal, so conforming parents no longer trigger a redundant stats recompute on every edit/feedback write. --- .../data-schemas/src/methods/chatProject.ts | 6 ++-- .../data-schemas/src/methods/message.spec.ts | 35 +++++++++++++++++++ packages/data-schemas/src/methods/message.ts | 22 +++++++++--- packages/data-schemas/src/utils/retention.ts | 13 ++++--- 4 files changed, 63 insertions(+), 13 deletions(-) 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; }; /**