fix: cap forced-retention edits to the message's earlier expiry

The edit/feedback path calls applyForcedRetention with capExpiryToConversation,
which unconditionally set the touched message's expiredAt to the parent/fresh
forced deadline. When that message already expired sooner (an older message
keeps its creation-time deadline while later saves refreshed the parent's), the
$set extended it past its own schedule, overwriting the earlier deadline that
capForcedRetentionToParent had just preserved.

Cap the touched message to min(existing expiredAt, forcedExpiredAt) via the same
$min/$ifNull pipeline forceConversationMessagesTemporary already uses, so a
message with a sooner deadline keeps it and a permanent message still receives
the forced one.
This commit is contained in:
Marco Beretta 2026-07-03 21:26:41 +02:00
parent 38057713ce
commit 7a923ef404
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 44 additions and 4 deletions

View file

@ -1128,6 +1128,42 @@ describe('Message Operations', () => {
}
});
it('preserves an edited message expiring sooner than its parent under forced retention', async () => {
const conversationId = uuidv4();
const editedMessageId = uuidv4();
const messageDeadline = new Date(Date.now() + 60 * 60 * 1000);
const parentDeadline = new Date(Date.now() + 12 * 60 * 60 * 1000);
await Conversation().create({
conversationId,
user: 'user123',
endpoint: 'openAI',
title: 'Carried-over chat',
isTemporary: false,
expiredAt: parentDeadline,
});
await Message.create({
messageId: editedMessageId,
conversationId,
user: 'user123',
text: 'edited',
isTemporary: false,
expiredAt: messageDeadline,
});
await applyForcedRetention(
{
userId: 'user123',
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
},
{ conversationId, messageId: editedMessageId },
{ context: 'edit', capExpiryToConversation: true },
);
const message = await Message.findOne({ messageId: editedMessageId, user: 'user123' }).lean();
expect(message?.isTemporary).toBe(true);
expect(message?.expiredAt?.getTime()).toBe(messageDeadline.getTime());
});
it('converts a permanent conversation and its messages without a messageId (tag write)', async () => {
const conversationId = uuidv4();
await Conversation().create({

View file

@ -422,10 +422,14 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
}
if (typeof messageId === 'string' && messageId.length > 0) {
await Message.updateOne(
{ messageId, user: userId },
{ $set: { isTemporary: true, expiredAt: forcedExpiredAt } },
);
await Message.updateOne({ messageId, user: userId }, [
{
$set: {
isTemporary: true,
expiredAt: { $min: [{ $ifNull: ['$expiredAt', forcedExpiredAt] }, forcedExpiredAt] },
},
},
]);
}
await cascadeForcedConversationRetention(
Conversation,