diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js index 05e5beba09..cd37c0f6d0 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.js +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js @@ -4,7 +4,7 @@ const Path = require('path') const Features = require('../../infrastructure/Features') module.exports = ProjectEditorHandler = { - trackChangesAvailable: false, + trackChangesAvailable: true, buildProjectModelView(project, members, invites) { let owner, ownerFeatures diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index a7ff970ef0..72db6cef36 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1005,6 +1005,7 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', + 'track-changes', ], viewIncludes: {}, diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index a4e2862e1f..bc7a99050d 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -185,7 +185,7 @@ function useCodeMirrorScope(view: EditorView) { if (currentDocument) { if (trackChanges) { - currentDocument.track_changes_as = userId || 'anonymous' + currentDocument.track_changes_as = userId || 'anonymous-user' } else { currentDocument.track_changes_as = null } diff --git a/services/web/modules/track-changes/app/src/TrackChangesController.js b/services/web/modules/track-changes/app/src/TrackChangesController.js new file mode 100644 index 0000000000..45f8a03e0f --- /dev/null +++ b/services/web/modules/track-changes/app/src/TrackChangesController.js @@ -0,0 +1,177 @@ +const ChatApiHandler = require('../../../../app/src/Features/Chat/ChatApiHandler') +const ChatManager = require('../../../../app/src/Features/Chat/ChatManager') +const EditorRealTimeController = require('../../../../app/src/Features/Editor/EditorRealTimeController') +const SessionManager = require('../../../../app/src/Features/Authentication/SessionManager') +const UserInfoManager = require('../../../../app/src/Features/User/UserInfoManager') +const UserInfoController = require('../../../../app/src/Features/User/UserInfoController') +const DocstoreManager = require('../../../../app/src/Features/Docstore/DocstoreManager') +const DocumentUpdaterHandler = require('../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler') +const CollaboratorsGetter = require('../../../../app/src/Features/Collaborators/CollaboratorsGetter') +const { Project } = require('../../../../app/src/models/Project') +const pLimit = require('p-limit') + +function _transformId(doc) { + if (doc._id) { + doc.id = doc._id + delete doc._id + } + return doc +} + +const TrackChangesController = { + async trackChanges(req, res, next) { + try { + const { project_id } = req.params + let state = req.body.on || req.body.on_for + if (req.body.on_for_guests && !req.body.on) state.__guests__ = true + await Project.updateOne({_id: project_id}, {track_changes: state}).exec() //do not wait? + EditorRealTimeController.emitToRoom(project_id, 'toggle-track-changes', state) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async acceptChanges(req, res, next) { + try { + const { project_id, doc_id } = req.params + const change_ids = req.body.change_ids + EditorRealTimeController.emitToRoom(project_id, 'accept-changes', doc_id, change_ids) + await DocumentUpdaterHandler.promises.acceptChanges(project_id, doc_id, change_ids) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async getAllRanges(req, res, next) { + try { + const { project_id } = req.params + await DocumentUpdaterHandler.promises.flushProjectToMongo(project_id) + const ranges = await DocstoreManager.promises.getAllRanges(project_id) + res.json(ranges.map(_transformId)) + } catch (err) { + next(err) + } + }, + async getChangesUsers(req, res, next) { +// This route was previously used by the frontend to retrieve names of users who made changes or comments. +// review-panel-new no longer needs this for comments, but still relies on it for changes - +// although the frontend knows the names of the current owner and members, it depends on the data +// provided here to assign names to authors who have left the project but still have unaccepted changes. + try { + const { project_id } = req.params + const memberIds = new Set() + const ranges = await DocstoreManager.promises.getAllRanges(project_id) + ranges.forEach(range => { + ;[...range.ranges?.changes || [], ...range.ranges?.comments || []].forEach(item => { + memberIds.add(item.metadata?.user_id) + }) + }) + const limit = pLimit(3) + const users = await Promise.all( + [...memberIds].map(memberId => + limit(async () => { + const user = await UserInfoManager.promises.getPersonalInfo(memberId) + return UserInfoController.formatPersonalInfo(user) + }) + ) + ) + res.json(users) + } catch (err) { + next(err) + } + }, + async getThreads(req, res, next) { + try { + const { project_id } = req.params + const messages = await ChatApiHandler.promises.getThreads(project_id) + await ChatManager.promises.injectUserInfoIntoThreads(messages) + res.json(messages) + } catch (err) { + next(err) + } + }, + async sendComment(req, res, next) { + try { + const { project_id, thread_id } = req.params + const { content } = req.body + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + const message = await ChatApiHandler.promises.sendComment(project_id, thread_id, user_id, content) + const user = await UserInfoManager.promises.getPersonalInfo(user_id) + message.user = UserInfoController.formatPersonalInfo(user) + EditorRealTimeController.emitToRoom(project_id, 'new-comment', thread_id, message) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async editMessage(req, res, next) { + try { + const { project_id, thread_id, message_id } = req.params + const { content } = req.body + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + await ChatApiHandler.promises.editMessage(project_id, thread_id, message_id, user_id, content) + EditorRealTimeController.emitToRoom(project_id, 'edit-message', thread_id, message_id, content) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async deleteMessage(req, res, next) { + try { + const { project_id, thread_id, message_id } = req.params + await ChatApiHandler.promises.deleteMessage(project_id, thread_id, message_id) + EditorRealTimeController.emitToRoom(project_id, 'delete-message', thread_id, message_id) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async resolveThread(req, res, next) { + try { + const { project_id, doc_id, thread_id } = req.params + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + const user = await UserInfoManager.promises.getPersonalInfo(user_id) + await ChatApiHandler.promises.resolveThread(project_id, thread_id, user_id) + EditorRealTimeController.emitToRoom( + project_id, + 'resolve-thread', + thread_id, + UserInfoController.formatPersonalInfo(user) + ) + await DocumentUpdaterHandler.promises.resolveThread(project_id, doc_id, thread_id, user_id) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async reopenThread(req, res, next) { + try { + const { project_id, doc_id, thread_id } = req.params + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + await ChatApiHandler.promises.reopenThread(project_id, thread_id) + EditorRealTimeController.emitToRoom(project_id, 'reopen-thread', thread_id) + await DocumentUpdaterHandler.promises.reopenThread(project_id, doc_id, thread_id, user_id) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, + async deleteThread(req, res, next) { + try { + const { project_id, doc_id, thread_id } = req.params + const user_id = SessionManager.getLoggedInUserId(req.session) + if (!user_id) throw new Error('no logged-in user') + await ChatApiHandler.promises.deleteThread(project_id, thread_id) + EditorRealTimeController.emitToRoom(project_id, 'delete-thread', thread_id) + await DocumentUpdaterHandler.promises.deleteThread(project_id, doc_id, thread_id, user_id) + res.sendStatus(204) + } catch (err) { + next(err) + } + }, +} +module.exports = TrackChangesController diff --git a/services/web/modules/track-changes/app/src/TrackChangesRouter.js b/services/web/modules/track-changes/app/src/TrackChangesRouter.js new file mode 100644 index 0000000000..3791e251a1 --- /dev/null +++ b/services/web/modules/track-changes/app/src/TrackChangesRouter.js @@ -0,0 +1,72 @@ +const logger = require('@overleaf/logger') +const AuthorizationMiddleware = require('../../../../app/src/Features/Authorization/AuthorizationMiddleware') +const TrackChangesController = require('./TrackChangesController') + +module.exports = { + apply(webRouter) { + logger.debug({}, 'Init track-changes router') + + webRouter.post('/project/:project_id/track_changes', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.trackChanges + ) + webRouter.post('/project/:project_id/doc/:doc_id/changes/accept', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.acceptChanges + ) + webRouter.get('/project/:project_id/ranges', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.getAllRanges + ) + webRouter.get('/project/:project_id/changes/users', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.getChangesUsers + ) + webRouter.get( + '/project/:project_id/threads', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.getThreads + ) + webRouter.post( + '/project/:project_id/thread/:thread_id/messages', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.sendComment + ) + webRouter.post( + '/project/:project_id/thread/:thread_id/messages/:message_id/edit', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.editMessage + ) + webRouter.delete( + '/project/:project_id/thread/:thread_id/messages/:message_id', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.deleteMessage + ) + webRouter.post( + '/project/:project_id/doc/:doc_id/thread/:thread_id/resolve', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.resolveThread + ) + webRouter.post( + '/project/:project_id/doc/:doc_id/thread/:thread_id/reopen', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.reopenThread + ) + webRouter.delete( + '/project/:project_id/doc/:doc_id/thread/:thread_id', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + TrackChangesController.deleteThread + ) + }, +} diff --git a/services/web/modules/track-changes/index.js b/services/web/modules/track-changes/index.js new file mode 100644 index 0000000000..aa9e6a73da --- /dev/null +++ b/services/web/modules/track-changes/index.js @@ -0,0 +1,2 @@ +const TrackChangesRouter = require('./app/src/TrackChangesRouter') +module.exports = { router : TrackChangesRouter }