From 304f572f9c261190a7e15ac18b5e6d88d97d5e0f Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Fri, 7 Jun 2024 12:06:23 +0100 Subject: [PATCH] Merge pull request #18637 from overleaf/mj-chat-duplicate-threads [chat] Add endpoint for duplicating comment threads GitOrigin-RevId: 0b3fb1b836150ccb6d213ab2bba6ce7ff6c69b4a --- .../Messages/MessageHttpController.js | 30 ++++++ .../js/Features/Messages/MessageManager.js | 13 +++ .../app/js/Features/Threads/ThreadManager.js | 22 +++++ services/chat/chat.yaml | 31 +++++++ .../js/CloningCommentThreadsTests.js | 93 +++++++++++++++++++ .../test/acceptance/js/helpers/ChatClient.js | 10 ++ 6 files changed, 199 insertions(+) create mode 100644 services/chat/test/acceptance/js/CloningCommentThreadsTests.js diff --git a/services/chat/app/js/Features/Messages/MessageHttpController.js b/services/chat/app/js/Features/Messages/MessageHttpController.js index ab6129a623..b9f513643b 100644 --- a/services/chat/app/js/Features/Messages/MessageHttpController.js +++ b/services/chat/app/js/Features/Messages/MessageHttpController.js @@ -82,6 +82,10 @@ export async function destroyProject(context) { return await callMessageHttpController(context, _destroyProject) } +export async function duplicateCommentThreads(context) { + return await callMessageHttpController(context, _duplicateCommentThreads) +} + export async function getStatus(context) { const message = 'chat is alive' context.res.status(200).setBody(message) @@ -254,3 +258,29 @@ async function _getMessages(clientThreadId, req, res) { logger.debug({ projectId, messages }, 'got messages') res.status(200).setBody(messages) } + +async function _duplicateCommentThreads(req, res) { + const { projectId } = req.params + const { threads } = req.body + const result = {} + for (const id of threads) { + logger.debug({ projectId, thread: id }, 'duplicating thread') + try { + const { oldRoom, newRoom } = await ThreadManager.duplicateThread( + projectId, + id + ) + await MessageManager.duplicateRoomToOtherRoom(oldRoom._id, newRoom._id) + result[id] = { duplicateId: newRoom.thread_id } + } catch (error) { + if (error instanceof ThreadManager.MissingThreadError) { + // Expected error when the comment has been deleted prior to duplication + result[id] = { error: 'not found' } + } else { + logger.err({ error }, 'error duplicating thread') + result[id] = { error: 'unknown' } + } + } + } + res.json({ newThreads: result }) +} diff --git a/services/chat/app/js/Features/Messages/MessageManager.js b/services/chat/app/js/Features/Messages/MessageManager.js index 77103f118a..cb8818e3b6 100644 --- a/services/chat/app/js/Features/Messages/MessageManager.js +++ b/services/chat/app/js/Features/Messages/MessageManager.js @@ -89,3 +89,16 @@ function _ensureIdsAreObjectIds(query) { } return query } + +export async function duplicateRoomToOtherRoom(sourceRoomId, targetRoomId) { + const sourceMessages = await findAllMessagesInRooms([sourceRoomId]) + const targetMessages = sourceMessages.map(comment => { + return _ensureIdsAreObjectIds({ + room_id: targetRoomId, + content: comment.content, + timestamp: comment.timestamp, + user_id: comment.user_id, + }) + }) + await db.messages.insertMany(targetMessages) +} diff --git a/services/chat/app/js/Features/Threads/ThreadManager.js b/services/chat/app/js/Features/Threads/ThreadManager.js index efda558a54..d7a666f082 100644 --- a/services/chat/app/js/Features/Threads/ThreadManager.js +++ b/services/chat/app/js/Features/Threads/ThreadManager.js @@ -1,5 +1,7 @@ import { db, ObjectId } from '../../mongodb.js' +export class MissingThreadError extends Error {} + export const GLOBAL_THREAD = 'GLOBAL' export async function findOrCreateThread(projectId, threadId) { @@ -124,3 +126,23 @@ export async function getResolvedThreadIds(projectId) { .toArray() return resolvedThreadIds } + +export async function duplicateThread(projectId, threadId) { + const room = await db.rooms.findOne({ + project_id: new ObjectId(projectId), + thread_id: new ObjectId(threadId), + }) + if (!room) { + throw new MissingThreadError('Trying to duplicate a non-existent thread') + } + const newRoom = { + project_id: room.project_id, + thread_id: new ObjectId(), + } + if (room.resolved) { + newRoom.resolved = room.resolved + } + const confirmation = await db.rooms.insertOne(newRoom) + newRoom._id = confirmation.insertedId + return { oldRoom: room, newRoom } +} diff --git a/services/chat/chat.yaml b/services/chat/chat.yaml index b328baeced..5ffbab17b4 100644 --- a/services/chat/chat.yaml +++ b/services/chat/chat.yaml @@ -303,6 +303,37 @@ paths: description: chat is alive operationId: getStatus description: Check that the Chat service is alive + '/project/{projectId}/duplicate-comment-threads': + parameters: + - schema: + type: string + name: projectId + in: path + required: true + post: + summary: Duplicate comment threads + operationId: duplicateCommentThreads + requestBody: + content: + application/json: + schema: + type: object + properties: + threads: + type: array + items: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + newThreads: + type: object + description: Mapping of old thread ids to their duplicated thread ids + description: Duplicate a list of comment threads components: schemas: Message: diff --git a/services/chat/test/acceptance/js/CloningCommentThreadsTests.js b/services/chat/test/acceptance/js/CloningCommentThreadsTests.js new file mode 100644 index 0000000000..f4adde1a52 --- /dev/null +++ b/services/chat/test/acceptance/js/CloningCommentThreadsTests.js @@ -0,0 +1,93 @@ +import { ObjectId } from '../../../app/js/mongodb.js' +import { expect } from 'chai' + +import * as ChatClient from './helpers/ChatClient.js' +import * as ChatApp from './helpers/ChatApp.js' + +const user1Id = new ObjectId().toString() +const user2Id = new ObjectId().toString() + +async function createCommentThread(projectId, threadId = new ObjectId()) { + const { response: response1 } = await ChatClient.sendMessage( + projectId, + threadId.toString(), + user1Id, + 'message 1' + ) + expect(response1.statusCode).to.equal(201) + const { response: response2 } = await ChatClient.sendMessage( + projectId, + threadId, + user2Id, + 'message 2' + ) + expect(response2.statusCode).to.equal(201) + return threadId.toString() +} + +describe('Cloning comment threads', async function () { + const projectId = new ObjectId().toString() + + before(async function () { + await ChatApp.ensureRunning() + this.thread1Id = await createCommentThread(projectId) + this.thread2Id = await createCommentThread(projectId) + this.thread3Id = await createCommentThread(projectId) + }) + + describe('with non-orphaned threads', async function () { + before(async function () { + const { + response: { body: result, statusCode }, + } = await ChatClient.duplicateCommentThreads(projectId, [this.thread3Id]) + this.result = result + expect(statusCode).to.equal(200) + expect(this.result).to.have.property('newThreads') + this.newThreadId = this.result.newThreads[this.thread3Id].duplicateId + }) + + it('should duplicate threads', function () { + expect(this.result.newThreads).to.have.property(this.thread3Id) + expect(this.result.newThreads[this.thread3Id]).to.have.property( + 'duplicateId' + ) + expect(this.result.newThreads[this.thread3Id].duplicateId).to.not.equal( + this.thread3Id + ) + }) + + it('should not duplicate other threads threads', function () { + expect(this.result.newThreads).to.not.have.property(this.thread1Id) + expect(this.result.newThreads).to.not.have.property(this.thread2Id) + }) + + it('should duplicate the messages in the thread', async function () { + const { + response: { body: threads }, + } = await ChatClient.getThreads(projectId) + function ignoreId(comment) { + return { + ...comment, + id: undefined, + } + } + expect(threads[this.thread3Id].messages.map(ignoreId)).to.deep.equal( + threads[this.newThreadId].messages.map(ignoreId) + ) + }) + + it('should have two separate unlinked threads', async function () { + await ChatClient.sendMessage( + projectId, + this.newThreadId, + user1Id, + 'third message' + ) + const { + response: { body: threads }, + } = await ChatClient.getThreads(projectId) + expect(threads[this.thread3Id].messages.length).to.equal(2) + expect(threads[this.newThreadId].messages.length).to.equal(3) + }) + }) +}) diff --git a/services/chat/test/acceptance/js/helpers/ChatClient.js b/services/chat/test/acceptance/js/helpers/ChatClient.js index 43be545ebc..857c3f7fc8 100644 --- a/services/chat/test/acceptance/js/helpers/ChatClient.js +++ b/services/chat/test/acceptance/js/helpers/ChatClient.js @@ -144,3 +144,13 @@ export async function destroyProject(projectId) { url: `/project/${projectId}`, }) } + +export async function duplicateCommentThreads(projectId, threads) { + return await asyncRequest({ + method: 'post', + url: `/project/${projectId}/duplicate-comment-threads`, + json: { + threads, + }, + }) +}