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 {