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.
This commit is contained in:
Marco Beretta 2026-07-05 02:37:20 +02:00
parent e9835fdb2f
commit 79d75ad8fc
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 63 additions and 13 deletions

View file

@ -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<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return Promise.resolve();
return;
}
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
return cascadeForcedConversationRetention(
await cascadeForcedConversationRetention(
Conversation,
Message,
SharedLink,

View file

@ -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<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 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<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

@ -215,7 +215,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
const File = mongoose.models.File as Model<IMongoFile>;
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<IConversation>);
}
}
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<IConversation>);
if (convertedParent) {
await refreshForcedRetentionProjectStats(userId, {
conversationId,
} as FilterQuery<IConversation>);
}
}
/**

View file

@ -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<IConversation>,
@ -234,13 +236,13 @@ export const cascadeForcedConversationRetention = async (
userId: string,
conversationId: string,
forcedExpiredAt: Date,
): Promise<void> => {
): Promise<boolean> => {
const parent = await Conversation.findOne(
{ conversationId, user: userId },
'isTemporary expiredAt',
).lean<RetentionFilterDocument | null>();
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<IConversation>(expiredAt) },
{ $set: { isTemporary: true, expiredAt } },
);
return (convoResult.modifiedCount ?? 0) > 0;
};
/**