🕰️ fix: Stop Pinning and Archiving From Counting as Chat Activity (#14861)

* fix: stop pinning and archiving from counting as chat activity

Both routes went through saveConvo, which lets mongoose stamp updatedAt.
The sidebar orders chats by that field, so pinning hoisted an untouched
chat to the top of Today, and unarchiving a year-old chat dropped it
there too instead of back into its own date group.

saveConvo now takes preserveUpdatedAt, and both routes pass it. They also
pass noUpsert: with timestamps suppressed an upsert would insert a
conversation carrying neither createdAt nor updatedAt, so an unknown
conversation id is now a 404 rather than a silently created stub.

* test: pin a project's activity pointer against metadata-only saves

Review raised that preserving updatedAt could drag a project's
lastConversationAt back to the pinned chat's older timestamp, since the
incremental path $sets it outright.

That path is not reachable here: a pin carries no chatProjectId, so
previousChatProjectId stays null while the conversation has a real one,
projectMembershipChanged is therefore true, and saveConvo takes the full
recompute branch instead. This test holds that in place, with a newer
sibling conversation in the project so a regression to the incremental
path would fail it.

* fix: keep updatedAt through the retention backfill

Under RetentionMode.ALL a legacy chat with no stored isTemporary gets a
second write after the main update, and that one still had mongoose
timestamps enabled. The first archive of such a chat therefore bumped
updatedAt anyway and landed in Today, defeating preserveUpdatedAt on
exactly the old conversations it was meant to protect.
This commit is contained in:
Marco Beretta 2026-08-16 22:20:20 +02:00 committed by GitHub
parent fb8ae881cf
commit 5fc05ac037
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 227 additions and 8 deletions

View file

@ -543,7 +543,11 @@ describe('Convos Routes', () => {
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'test-user-123' }),
{ conversationId: mockConversationId, isArchived: true },
{ context: `POST /api/convos/archive ${mockConversationId}` },
{
context: `POST /api/convos/archive ${mockConversationId}`,
preserveUpdatedAt: true,
noUpsert: true,
},
);
});
@ -572,7 +576,11 @@ describe('Convos Routes', () => {
expect(saveConvo).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'test-user-123' }),
{ conversationId: mockConversationId, isArchived: false },
{ context: `POST /api/convos/archive ${mockConversationId}` },
{
context: `POST /api/convos/archive ${mockConversationId}`,
preserveUpdatedAt: true,
noUpsert: true,
},
);
});
@ -663,7 +671,11 @@ describe('Convos Routes', () => {
expect(saveConvo).toHaveBeenCalledWith(
{ userId: 'test-user-123' },
{ conversationId: mockConversationId, pinned: true },
{ context: `POST /api/convos/pin ${mockConversationId}` },
{
context: `POST /api/convos/pin ${mockConversationId}`,
preserveUpdatedAt: true,
noUpsert: true,
},
);
});
@ -678,10 +690,27 @@ describe('Convos Routes', () => {
expect(saveConvo).toHaveBeenCalledWith(
{ userId: 'test-user-123' },
{ conversationId: mockConversationId, pinned: false },
{ context: `POST /api/convos/pin ${mockConversationId}` },
{
context: `POST /api/convos/pin ${mockConversationId}`,
preserveUpdatedAt: true,
noUpsert: true,
},
);
});
/** With timestamps suppressed, an upsert would insert a conversation carrying
* neither createdAt nor updatedAt, so an unknown id has to be rejected instead. */
it('should return 404 when the conversation does not exist', async () => {
saveConvo.mockResolvedValue(null);
const response = await request(app)
.post('/api/convos/pin')
.send({ arg: { conversationId: 'missing-convo', pinned: true } });
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'Conversation not found' });
});
it('should return 400 when conversationId is missing', async () => {
const response = await request(app)
.post('/api/convos/pin')

View file

@ -208,8 +208,20 @@ router.post('/archive', validateConvoAccess, async (req, res) => {
interfaceConfig: req?.config?.interfaceConfig,
},
{ conversationId, isArchived },
{ context: `POST /api/convos/archive ${conversationId}` },
{
context: `POST /api/convos/archive ${conversationId}`,
/** Filing a chat away is not activity: `updatedAt` stays the chat's own last
* activity so unarchiving restores it to its real place in the date groups. */
preserveUpdatedAt: true,
/** Without timestamps, an upsert would insert a conversation that has none. */
noUpsert: true,
},
);
if (!dbResponse) {
return res.status(404).json({ error: 'Conversation not found' });
}
res.status(200).json(dbResponse);
} catch (error) {
logger.error('Error archiving conversation', error);
@ -236,8 +248,19 @@ router.post('/pin', validateConvoAccess, async (req, res) => {
const dbResponse = await db.saveConvo(
{ userId: req.user.id },
{ conversationId, pinned },
{ context: `POST /api/convos/pin ${conversationId}` },
{
context: `POST /api/convos/pin ${conversationId}`,
/** Pinning is a bookmark, not activity, so the sidebar's `updatedAt` order stands. */
preserveUpdatedAt: true,
/** Without timestamps, an upsert would insert a conversation that has none. */
noUpsert: true,
},
);
if (!dbResponse) {
return res.status(404).json({ error: 'Conversation not found' });
}
res.status(200).json(dbResponse);
} catch (error) {
logger.error('Error pinning conversation', error);