From b94388ce9d96ff81fefad5dfbc424efc6e193a8b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 2 Jan 2026 19:41:53 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=BA=20fix:=20Restore=20Archive=20Funct?= =?UTF-8?q?ionality=20with=20Dedicated=20Endpoint=20(#11183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The archive conversation feature was broken after the `/api/convos/update` route was modified to only handle title updates. The frontend was sending `{ conversationId, isArchived }` to the update endpoint, but the backend was only extracting `title` and ignoring the `isArchived` field entirely. This fix implements a dedicated `/api/convos/archive` endpoint to restore the archive/unarchive functionality. Changes: packages/data-provider/src/api-endpoints.ts: - Add `archiveConversation()` endpoint returning `/api/convos/archive` packages/data-provider/src/data-service.ts: - Update `archiveConversation()` to use dedicated archive endpoint api/server/routes/convos.js: - Add `POST /archive` route with validation for `conversationId` (required) and `isArchived` (must be boolean) api/server/routes/__tests__/convos.spec.js: - Add test coverage for archive endpoint (success, validation, error cases) --- api/server/routes/__tests__/convos.spec.js | 134 +++++++++++++++++++- api/server/routes/convos.js | 31 +++++ packages/data-provider/src/api-endpoints.ts | 2 + packages/data-provider/src/data-service.ts | 2 +- 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index ce43155cb0..ef11b3cbbb 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -109,7 +109,7 @@ describe('Convos Routes', () => { let app; let convosRouter; const { deleteAllSharedLinks, deleteConvoSharedLink } = require('~/models'); - const { deleteConvos } = require('~/models/Conversation'); + const { deleteConvos, saveConvo } = require('~/models/Conversation'); const { deleteToolCalls } = require('~/models/ToolCall'); beforeAll(() => { @@ -461,6 +461,138 @@ describe('Convos Routes', () => { expect(deleteConvoSharedLink).toHaveBeenCalledAfter(deleteConvos); }); }); + + describe('POST /archive', () => { + it('should archive a conversation successfully', async () => { + const mockConversationId = 'conv-123'; + const mockArchivedConvo = { + conversationId: mockConversationId, + title: 'Test Conversation', + isArchived: true, + user: 'test-user-123', + }; + + saveConvo.mockResolvedValue(mockArchivedConvo); + + const response = await request(app) + .post('/api/convos/archive') + .send({ + arg: { + conversationId: mockConversationId, + isArchived: true, + }, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockArchivedConvo); + expect(saveConvo).toHaveBeenCalledWith( + expect.objectContaining({ user: { id: 'test-user-123' } }), + { conversationId: mockConversationId, isArchived: true }, + { context: `POST /api/convos/archive ${mockConversationId}` }, + ); + }); + + it('should unarchive a conversation successfully', async () => { + const mockConversationId = 'conv-456'; + const mockUnarchivedConvo = { + conversationId: mockConversationId, + title: 'Unarchived Conversation', + isArchived: false, + user: 'test-user-123', + }; + + saveConvo.mockResolvedValue(mockUnarchivedConvo); + + const response = await request(app) + .post('/api/convos/archive') + .send({ + arg: { + conversationId: mockConversationId, + isArchived: false, + }, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockUnarchivedConvo); + expect(saveConvo).toHaveBeenCalledWith( + expect.objectContaining({ user: { id: 'test-user-123' } }), + { conversationId: mockConversationId, isArchived: false }, + { context: `POST /api/convos/archive ${mockConversationId}` }, + ); + }); + + it('should return 400 when conversationId is missing', async () => { + const response = await request(app) + .post('/api/convos/archive') + .send({ + arg: { + isArchived: true, + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'conversationId is required' }); + expect(saveConvo).not.toHaveBeenCalled(); + }); + + it('should return 400 when isArchived is not a boolean', async () => { + const response = await request(app) + .post('/api/convos/archive') + .send({ + arg: { + conversationId: 'conv-123', + isArchived: 'true', + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'isArchived must be a boolean' }); + expect(saveConvo).not.toHaveBeenCalled(); + }); + + it('should return 400 when isArchived is undefined', async () => { + const response = await request(app) + .post('/api/convos/archive') + .send({ + arg: { + conversationId: 'conv-123', + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'isArchived must be a boolean' }); + expect(saveConvo).not.toHaveBeenCalled(); + }); + + it('should return 500 when saveConvo fails', async () => { + const mockConversationId = 'conv-error'; + saveConvo.mockRejectedValue(new Error('Database error')); + + const response = await request(app) + .post('/api/convos/archive') + .send({ + arg: { + conversationId: mockConversationId, + isArchived: true, + }, + }); + + expect(response.status).toBe(500); + expect(response.text).toBe('Error archiving conversation'); + + const { logger } = require('@librechat/data-schemas'); + expect(logger.error).toHaveBeenCalledWith('Error archiving conversation', expect.any(Error)); + }); + + it('should handle empty arg object', async () => { + const response = await request(app).post('/api/convos/archive').send({ + arg: {}, + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'conversationId is required' }); + }); + }); }); /** diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index e862f99ab0..faf4d6ac4f 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -152,6 +152,37 @@ router.delete('/all', async (req, res) => { } }); +/** + * Archives or unarchives a conversation. + * @route POST /archive + * @param {string} req.body.arg.conversationId - The conversation ID to archive/unarchive. + * @param {boolean} req.body.arg.isArchived - Whether to archive (true) or unarchive (false). + * @returns {object} 200 - The updated conversation object. + */ +router.post('/archive', validateConvoAccess, async (req, res) => { + const { conversationId, isArchived } = req.body.arg ?? {}; + + if (!conversationId) { + return res.status(400).json({ error: 'conversationId is required' }); + } + + if (typeof isArchived !== 'boolean') { + return res.status(400).json({ error: 'isArchived must be a boolean' }); + } + + try { + const dbResponse = await saveConvo( + req, + { conversationId, isArchived }, + { context: `POST /api/convos/archive ${conversationId}` }, + ); + res.status(200).json(dbResponse); + } catch (error) { + logger.error('Error archiving conversation', error); + res.status(500).send('Error archiving conversation'); + } +}); + /** Maximum allowed length for conversation titles */ const MAX_CONVO_TITLE_LENGTH = 1024; diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 0490bb351c..ef7e0d6cfb 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -108,6 +108,8 @@ export const genTitle = (conversationId: string) => export const updateConversation = () => `${conversationsRoot}/update`; +export const archiveConversation = () => `${conversationsRoot}/archive`; + export const deleteConversation = () => `${conversationsRoot}`; export const deleteAllConversation = () => `${conversationsRoot}/all`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 9122f3c4fb..1c8199ce7a 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -720,7 +720,7 @@ export function updateConversation( export function archiveConversation( payload: t.TArchiveConversationRequest, ): Promise { - return request.post(endpoints.updateConversation(), { arg: payload }); + return request.post(endpoints.archiveConversation(), { arg: payload }); } export function genTitle(payload: m.TGenTitleRequest): Promise {