Преглед на файлове

Merge pull request #24433 from overleaf/em-pending-reviewers

Support reviewers in the collaborator limit enforcement logic

GitOrigin-RevId: f11a8e37ca6ef36f9894233803c6ee8363bf0ff8
Eric Mc Sween преди 1 година
родител
ревизия
f46fd6f2d5

+ 30 - 12
services/web/app/src/Features/Collaborators/CollaboratorsGetter.js

@@ -60,6 +60,7 @@ async function getMemberIdsWithPrivilegeLevels(projectId) {
     publicAccesLevel: 1,
     publicAccesLevel: 1,
     pendingEditor_refs: 1,
     pendingEditor_refs: 1,
     reviewer_refs: 1,
     reviewer_refs: 1,
+    pendingReviewer_refs: 1,
   })
   })
   if (!project) {
   if (!project) {
     throw new Errors.NotFoundError(`no project found with id ${projectId}`)
     throw new Errors.NotFoundError(`no project found with id ${projectId}`)
@@ -72,7 +73,8 @@ async function getMemberIdsWithPrivilegeLevels(projectId) {
     project.tokenAccessReadOnly_refs,
     project.tokenAccessReadOnly_refs,
     project.publicAccesLevel,
     project.publicAccesLevel,
     project.pendingEditor_refs,
     project.pendingEditor_refs,
-    project.reviewer_refs
+    project.reviewer_refs,
+    project.pendingReviewer_refs
   )
   )
   return memberIds
   return memberIds
 }
 }
@@ -107,7 +109,8 @@ async function getInvitedMembersWithPrivilegeLevelsFromFields(
     [],
     [],
     null,
     null,
     [],
     [],
-    reviewerIds
+    reviewerIds,
+    []
   )
   )
   return _loadMembers(members)
   return _loadMembers(members)
 }
 }
@@ -139,13 +142,14 @@ async function getInvitedEditCollaboratorCount(projectId) {
 }
 }
 
 
 async function getInvitedPendingEditorCount(projectId) {
 async function getInvitedPendingEditorCount(projectId) {
-  // Only counts invited members that are readonly pending editors
+  // Only counts invited members that are readonly pending editors or pending
+  // reviewers
   const members = await getMemberIdsWithPrivilegeLevels(projectId)
   const members = await getMemberIdsWithPrivilegeLevels(projectId)
   return members.filter(
   return members.filter(
     m =>
     m =>
       m.source === Sources.INVITE &&
       m.source === Sources.INVITE &&
       m.privilegeLevel === PrivilegeLevels.READ_ONLY &&
       m.privilegeLevel === PrivilegeLevels.READ_ONLY &&
-      m.pendingEditor === true
+      (m.pendingEditor || m.pendingReviewer)
   ).length
   ).length
 }
 }
 
 
@@ -320,7 +324,8 @@ function _getMemberIdsWithPrivilegeLevelsFromFields(
   tokenAccessReadOnlyIds,
   tokenAccessReadOnlyIds,
   publicAccessLevel,
   publicAccessLevel,
   pendingEditorIds,
   pendingEditorIds,
-  reviewerIds
+  reviewerIds,
+  pendingReviewerIds
 ) {
 ) {
   const members = []
   const members = []
   members.push({
   members.push({
@@ -328,6 +333,7 @@ function _getMemberIdsWithPrivilegeLevelsFromFields(
     privilegeLevel: PrivilegeLevels.OWNER,
     privilegeLevel: PrivilegeLevels.OWNER,
     source: Sources.OWNER,
     source: Sources.OWNER,
   })
   })
+
   for (const memberId of collaboratorIds || []) {
   for (const memberId of collaboratorIds || []) {
     members.push({
     members.push({
       id: memberId.toString(),
       id: memberId.toString(),
@@ -335,16 +341,22 @@ function _getMemberIdsWithPrivilegeLevelsFromFields(
       source: Sources.INVITE,
       source: Sources.INVITE,
     })
     })
   }
   }
+
   for (const memberId of readOnlyIds || []) {
   for (const memberId of readOnlyIds || []) {
-    members.push({
+    const record = {
       id: memberId.toString(),
       id: memberId.toString(),
       privilegeLevel: PrivilegeLevels.READ_ONLY,
       privilegeLevel: PrivilegeLevels.READ_ONLY,
       source: Sources.INVITE,
       source: Sources.INVITE,
-      ...(pendingEditorIds?.some(pe => memberId.equals(pe)) && {
-        pendingEditor: true,
-      }),
-    })
+    }
+
+    if (pendingEditorIds?.some(pe => memberId.equals(pe))) {
+      record.pendingEditor = true
+    } else if (pendingReviewerIds?.some(pr => memberId.equals(pr))) {
+      record.pendingReviewer = true
+    }
+    members.push(record)
   }
   }
+
   if (publicAccessLevel === PublicAccessLevels.TOKEN_BASED) {
   if (publicAccessLevel === PublicAccessLevels.TOKEN_BASED) {
     for (const memberId of tokenAccessIds || []) {
     for (const memberId of tokenAccessIds || []) {
       members.push({
       members.push({
@@ -361,6 +373,7 @@ function _getMemberIdsWithPrivilegeLevelsFromFields(
       })
       })
     }
     }
   }
   }
+
   for (const memberId of reviewerIds || []) {
   for (const memberId of reviewerIds || []) {
     members.push({
     members.push({
       id: memberId.toString(),
       id: memberId.toString(),
@@ -385,11 +398,16 @@ async function _loadMembers(members) {
           signUpDate: 1,
           signUpDate: 1,
         })
         })
         if (user != null) {
         if (user != null) {
-          return {
+          const record = {
             user,
             user,
             privilegeLevel: member.privilegeLevel,
             privilegeLevel: member.privilegeLevel,
-            ...(member.pendingEditor && { pendingEditor: true }),
           }
           }
+          if (member.pendingEditor) {
+            record.pendingEditor = true
+          } else if (member.pendingReviewer) {
+            record.pendingReviewer = true
+          }
+          return record
         } else {
         } else {
           return null
           return null
         }
         }

+ 36 - 3
services/web/app/src/Features/Collaborators/CollaboratorsHandler.js

@@ -53,6 +53,7 @@ async function removeUserFromProject(projectId, userId) {
             reviewer_refs: userId,
             reviewer_refs: userId,
             readOnly_refs: userId,
             readOnly_refs: userId,
             pendingEditor_refs: userId,
             pendingEditor_refs: userId,
+            pendingReviewer_refs: userId,
             tokenAccessReadOnly_refs: userId,
             tokenAccessReadOnly_refs: userId,
             tokenAccessReadAndWrite_refs: userId,
             tokenAccessReadAndWrite_refs: userId,
             trashed: userId,
             trashed: userId,
@@ -68,6 +69,7 @@ async function removeUserFromProject(projectId, userId) {
             readOnly_refs: userId,
             readOnly_refs: userId,
             reviewer_refs: userId,
             reviewer_refs: userId,
             pendingEditor_refs: userId,
             pendingEditor_refs: userId,
+            pendingReviewer_refs: userId,
             tokenAccessReadOnly_refs: userId,
             tokenAccessReadOnly_refs: userId,
             tokenAccessReadAndWrite_refs: userId,
             tokenAccessReadAndWrite_refs: userId,
             archived: userId,
             archived: userId,
@@ -106,7 +108,7 @@ async function addUserIdToProject(
   addingUserId,
   addingUserId,
   userId,
   userId,
   privilegeLevel,
   privilegeLevel,
-  { pendingEditor } = {}
+  { pendingEditor, pendingReviewer } = {}
 ) {
 ) {
   const project = await ProjectGetter.promises.getProject(projectId, {
   const project = await ProjectGetter.promises.getProject(projectId, {
     owner_ref: 1,
     owner_ref: 1,
@@ -133,9 +135,17 @@ async function addUserIdToProject(
     level = { readOnly_refs: userId }
     level = { readOnly_refs: userId }
     if (pendingEditor) {
     if (pendingEditor) {
       level.pendingEditor_refs = userId
       level.pendingEditor_refs = userId
+    } else if (pendingReviewer) {
+      level.pendingReviewer_refs = userId
     }
     }
     logger.debug(
     logger.debug(
-      { privileges: 'readOnly', userId, projectId, pendingEditor },
+      {
+        privileges: 'readOnly',
+        userId,
+        projectId,
+        pendingEditor,
+        pendingReviewer,
+      },
       'adding user'
       'adding user'
     )
     )
   } else if (privilegeLevel === PrivilegeLevels.REVIEW) {
   } else if (privilegeLevel === PrivilegeLevels.REVIEW) {
@@ -246,6 +256,19 @@ async function transferProjects(fromUserId, toUserId) {
     }
     }
   ).exec()
   ).exec()
 
 
+  await Project.updateMany(
+    { pendingReviewer_refs: fromUserId },
+    {
+      $addToSet: { pendingReviewer_refs: toUserId },
+    }
+  ).exec()
+  await Project.updateMany(
+    { pendingReviewer_refs: fromUserId },
+    {
+      $pull: { pendingReviewer_refs: fromUserId },
+    }
+  ).exec()
+
   // Flush in background, no need to block on this
   // Flush in background, no need to block on this
   _flushProjects(projectIds).catch(err => {
   _flushProjects(projectIds).catch(err => {
     logger.err(
     logger.err(
@@ -259,7 +282,7 @@ async function setCollaboratorPrivilegeLevel(
   projectId,
   projectId,
   userId,
   userId,
   privilegeLevel,
   privilegeLevel,
-  { pendingEditor } = {}
+  { pendingEditor, pendingReviewer } = {}
 ) {
 ) {
   // Make sure we're only updating the project if the user is already a
   // Make sure we're only updating the project if the user is already a
   // collaborator
   // collaborator
@@ -279,6 +302,7 @@ async function setCollaboratorPrivilegeLevel(
           readOnly_refs: userId,
           readOnly_refs: userId,
           pendingEditor_refs: userId,
           pendingEditor_refs: userId,
           reviewer_refs: userId,
           reviewer_refs: userId,
+          pendingReviewer_refs: userId,
         },
         },
         $addToSet: { collaberator_refs: userId },
         $addToSet: { collaberator_refs: userId },
       }
       }
@@ -290,6 +314,7 @@ async function setCollaboratorPrivilegeLevel(
           readOnly_refs: userId,
           readOnly_refs: userId,
           pendingEditor_refs: userId,
           pendingEditor_refs: userId,
           collaberator_refs: userId,
           collaberator_refs: userId,
+          pendingReviewer_refs: userId,
         },
         },
         $addToSet: { reviewer_refs: userId },
         $addToSet: { reviewer_refs: userId },
       }
       }
@@ -316,11 +341,19 @@ async function setCollaboratorPrivilegeLevel(
         $pull: { collaberator_refs: userId, reviewer_refs: userId },
         $pull: { collaberator_refs: userId, reviewer_refs: userId },
         $addToSet: { readOnly_refs: userId },
         $addToSet: { readOnly_refs: userId },
       }
       }
+
       if (pendingEditor) {
       if (pendingEditor) {
         update.$addToSet.pendingEditor_refs = userId
         update.$addToSet.pendingEditor_refs = userId
       } else {
       } else {
         update.$pull.pendingEditor_refs = userId
         update.$pull.pendingEditor_refs = userId
       }
       }
+
+      if (pendingReviewer) {
+        update.$addToSet.pendingReviewer_refs = userId
+      } else {
+        update.$pull.pendingReviewer_refs = userId
+      }
+
       break
       break
     }
     }
     default: {
     default: {

+ 38 - 19
services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs

@@ -152,33 +152,52 @@ const CollaboratorsInviteHandler = {
     const project = await ProjectGetter.promises.getProject(projectId, {
     const project = await ProjectGetter.promises.getProject(projectId, {
       owner_ref: 1,
       owner_ref: 1,
     })
     })
-    const pendingEditor =
-      invite.privileges === PrivilegeLevels.READ_AND_WRITE &&
-      !(await LimitationsManager.promises.canAcceptEditCollaboratorInvite(
-        project._id
-      ))
-    if (pendingEditor) {
-      logger.debug(
-        { projectId, userId: user._id },
-        'no collaborator slots available, user added as read only (pending editor)'
+
+    let privilegeLevel = invite.privileges
+    const opts = {}
+    if (
+      [PrivilegeLevels.READ_AND_WRITE, PrivilegeLevels.REVIEW].includes(
+        invite.privileges
       )
       )
-      await ProjectAuditLogHandler.promises.addEntry(
-        projectId,
-        'editor-moved-to-pending', // controller already logged accept-invite
-        null,
-        null,
-        {
-          userId: user._id.toString(),
+    ) {
+      const allowed =
+        await LimitationsManager.promises.canAcceptEditCollaboratorInvite(
+          project._id
+        )
+      if (!allowed) {
+        privilegeLevel = PrivilegeLevels.READ_ONLY
+        if (invite.privileges === PrivilegeLevels.READ_AND_WRITE) {
+          opts.pendingEditor = true
+        } else if (invite.privileges === PrivilegeLevels.REVIEW) {
+          opts.pendingReviewer = true
         }
         }
-      )
+
+        logger.debug(
+          { projectId, userId: user._id, privileges: invite.privileges },
+          'no collaborator slots available, user added as read only (pending editor)'
+        )
+        await ProjectAuditLogHandler.promises.addEntry(
+          projectId,
+          'editor-moved-to-pending', // controller already logged accept-invite
+          null,
+          null,
+          {
+            userId: user._id.toString(),
+            role:
+              invite.privileges === PrivilegeLevels.REVIEW
+                ? 'reviewer'
+                : 'editor',
+          }
+        )
+      }
     }
     }
 
 
     await CollaboratorsHandler.promises.addUserIdToProject(
     await CollaboratorsHandler.promises.addUserIdToProject(
       projectId,
       projectId,
       invite.sendingUserId,
       invite.sendingUserId,
       user._id,
       user._id,
-      pendingEditor ? PrivilegeLevels.READ_ONLY : invite.privileges,
-      { pendingEditor }
+      privilegeLevel,
+      opts
     )
     )
 
 
     // Remove invite
     // Remove invite

+ 1 - 0
services/web/app/src/Features/Project/ProjectEditorHandler.js

@@ -107,6 +107,7 @@ module.exports = ProjectEditorHandler = {
       privileges: member.privilegeLevel,
       privileges: member.privilegeLevel,
       signUpDate: user.signUpDate,
       signUpDate: user.signUpDate,
       pendingEditor: member.pendingEditor,
       pendingEditor: member.pendingEditor,
+      pendingReviewer: member.pendingReviewer,
     }
     }
   },
   },
 
 

+ 2 - 3
services/web/app/src/Features/Subscription/LimitationsManager.js

@@ -84,9 +84,8 @@ async function canChangeCollaboratorPrivilegeLevel(
       projectId
       projectId
     )
     )
   if (
   if (
-    [PrivilegeLevels.READ_AND_WRITE, PrivilegeLevels.REVIEW].includes(
-      currentPrivilegeLevel
-    )
+    currentPrivilegeLevel === PrivilegeLevels.READ_AND_WRITE ||
+    currentPrivilegeLevel === PrivilegeLevels.REVIEW
   ) {
   ) {
     // Current collaborator already takes a slot, so changing the privilege
     // Current collaborator already takes a slot, so changing the privilege
     // level won't increase the collaborator count
     // level won't increase the collaborator count

+ 1 - 0
services/web/app/src/models/Project.js

@@ -41,6 +41,7 @@ const ProjectSchema = new Schema(
     reviewer_refs: [{ type: ObjectId, ref: 'User' }],
     reviewer_refs: [{ type: ObjectId, ref: 'User' }],
     readOnly_refs: [{ type: ObjectId, ref: 'User' }],
     readOnly_refs: [{ type: ObjectId, ref: 'User' }],
     pendingEditor_refs: [{ type: ObjectId, ref: 'User' }],
     pendingEditor_refs: [{ type: ObjectId, ref: 'User' }],
+    pendingReviewer_refs: [{ type: ObjectId, ref: 'User' }],
     rootDoc_id: { type: ObjectId },
     rootDoc_id: { type: ObjectId },
     rootFolder: [FolderSchema],
     rootFolder: [FolderSchema],
     mainBibliographyDoc_id: { type: ObjectId },
     mainBibliographyDoc_id: { type: ObjectId },

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -1975,6 +1975,7 @@
   "view_more": "",
   "view_more": "",
   "view_only_access": "",
   "view_only_access": "",
   "view_only_downgraded": "",
   "view_only_downgraded": "",
+  "view_only_reviewer_downgraded": "",
   "view_options": "",
   "view_options": "",
   "view_pdf": "",
   "view_pdf": "",
   "view_your_invoices": "",
   "view_your_invoices": "",

+ 15 - 3
services/web/frontend/js/features/share-project-modal/components/edit-member.tsx

@@ -73,7 +73,10 @@ export default function EditMember({
   }
   }
 
 
   function shouldWarnMember() {
   function shouldWarnMember() {
-    return hasExceededCollaboratorLimit && privileges === 'readAndWrite'
+    return (
+      hasExceededCollaboratorLimit &&
+      ['readAndWrite', 'review'].includes(privileges)
+    )
   }
   }
 
 
   function commitPrivilegeChange(newPrivileges: PermissionsOption) {
   function commitPrivilegeChange(newPrivileges: PermissionsOption) {
@@ -145,12 +148,16 @@ export default function EditMember({
           <div className="project-member-email-icon">
           <div className="project-member-email-icon">
             <MaterialIcon
             <MaterialIcon
               type={
               type={
-                shouldWarnMember() || member.pendingEditor
+                shouldWarnMember() ||
+                member.pendingEditor ||
+                member.pendingReviewer
                   ? 'warning'
                   ? 'warning'
                   : 'person'
                   : 'person'
               }
               }
               className={
               className={
-                shouldWarnMember() || member.pendingEditor
+                shouldWarnMember() ||
+                member.pendingEditor ||
+                member.pendingReviewer
                   ? 'project-member-warning'
                   ? 'project-member-warning'
                   : undefined
                   : undefined
               }
               }
@@ -160,6 +167,11 @@ export default function EditMember({
               {member.pendingEditor && (
               {member.pendingEditor && (
                 <div className="subtitle">{t('view_only_downgraded')}</div>
                 <div className="subtitle">{t('view_only_downgraded')}</div>
               )}
               )}
+              {member.pendingReviewer && (
+                <div className="subtitle">
+                  {t('view_only_reviewer_downgraded')}
+                </div>
+              )}
               {shouldWarnMember() && (
               {shouldWarnMember() && (
                 <div className="subtitle">
                 <div className="subtitle">
                   {t('will_lose_edit_access_on_date', {
                   {t('will_lose_edit_access_on_date', {

+ 16 - 5
services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx

@@ -41,8 +41,10 @@ export default function ShareModalBody() {
   // for moving between warning and info notification states etc.
   // for moving between warning and info notification states etc.
   const somePendingEditorsResolved = useMemo(() => {
   const somePendingEditorsResolved = useMemo(() => {
     return (
     return (
-      members.some(member => member.privileges === 'readAndWrite') &&
-      members.some(member => member.pendingEditor)
+      members.some(member =>
+        ['readAndWrite', 'review'].includes(member.privileges)
+      ) &&
+      members.some(member => member.pendingEditor || member.pendingReviewer)
     )
     )
   }, [members])
   }, [members])
 
 
@@ -54,7 +56,9 @@ export default function ShareModalBody() {
     if (features.collaborators === -1) {
     if (features.collaborators === -1) {
       return false
       return false
     }
     }
-    return members.some(member => member.pendingEditor)
+    return members.some(
+      member => member.pendingEditor || member.pendingReviewer
+    )
   }, [features, isProjectOwner, members])
   }, [features, isProjectOwner, members])
 
 
   const hasExceededCollaboratorLimit = useMemo(() => {
   const hasExceededCollaboratorLimit = useMemo(() => {
@@ -76,8 +80,13 @@ export default function ShareModalBody() {
     return [
     return [
       ...members.filter(member => member.privileges === 'readAndWrite'),
       ...members.filter(member => member.privileges === 'readAndWrite'),
       ...members.filter(member => member.pendingEditor),
       ...members.filter(member => member.pendingEditor),
+      ...members.filter(member => member.privileges === 'review'),
+      ...members.filter(member => member.pendingReviewer),
       ...members.filter(
       ...members.filter(
-        member => !member.pendingEditor && member.privileges !== 'readAndWrite'
+        member =>
+          !member.pendingEditor &&
+          !member.pendingReviewer &&
+          !['readAndWrite', 'review'].includes(member.privileges)
       ),
       ),
     ]
     ]
   }, [members])
   }, [members])
@@ -104,7 +113,9 @@ export default function ShareModalBody() {
             key={member._id}
             key={member._id}
             member={member}
             member={member}
             hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
             hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
-            hasBeenDowngraded={member.pendingEditor ?? false}
+            hasBeenDowngraded={Boolean(
+              member.pendingEditor || member.pendingReviewer
+            )}
             canAddCollaborators={canAddCollaborators}
             canAddCollaborators={canAddCollaborators}
           />
           />
         ) : (
         ) : (

+ 6 - 3
services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx

@@ -78,9 +78,12 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
         return false
         return false
       }
       }
       return (
       return (
-        project.members.filter(member => member.privileges === 'readAndWrite')
-          .length > (project.features.collaborators ?? 1) ||
-        project.members.some(member => member.pendingEditor)
+        project.members.filter(member =>
+          ['readAndWrite', 'review'].includes(member.privileges)
+        ).length > (project.features.collaborators ?? 1) ||
+        project.members.some(
+          member => member.pendingEditor || member.pendingReviewer
+        )
       )
       )
     }
     }
 
 

+ 5 - 1
services/web/frontend/js/shared/context/editor-context.tsx

@@ -116,7 +116,11 @@ export const EditorProvider: FC = ({ children }) => {
 
 
   const isPendingEditor = useMemo(
   const isPendingEditor = useMemo(
     () =>
     () =>
-      members?.some(member => member._id === userId && member.pendingEditor),
+      members?.some(
+        member =>
+          member._id === userId &&
+          (member.pendingEditor || member.pendingReviewer)
+      ),
     [members, userId]
     [members, userId]
   )
   )
 
 

+ 1 - 0
services/web/frontend/js/shared/context/types/project-context.tsx

@@ -9,6 +9,7 @@ export type ProjectContextMember = {
   first_name: string
   first_name: string
   last_name: string
   last_name: string
   pendingEditor?: boolean
   pendingEditor?: boolean
+  pendingReviewer?: boolean
 }
 }
 
 
 export type ProjectContextValue = {
 export type ProjectContextValue = {

+ 1 - 0
services/web/locales/en.json

@@ -2522,6 +2522,7 @@
   "view_more": "View more",
   "view_more": "View more",
   "view_only_access": "View-only access",
   "view_only_access": "View-only access",
   "view_only_downgraded": "View only. Upgrade to restore edit access.",
   "view_only_downgraded": "View only. Upgrade to restore edit access.",
+  "view_only_reviewer_downgraded": "View only. Upgrade to restore review access.",
   "view_options": "View options",
   "view_options": "View options",
   "view_pdf": "View PDF",
   "view_pdf": "View PDF",
   "view_source": "View Source",
   "view_source": "View Source",

+ 4 - 0
services/web/test/acceptance/src/helpers/User.mjs

@@ -992,6 +992,10 @@ class User {
       updateOp = {
       updateOp = {
         $addToSet: { readOnly_refs: user._id, pendingEditor_refs: user._id },
         $addToSet: { readOnly_refs: user._id, pendingEditor_refs: user._id },
       }
       }
+    } else if (privileges === 'pendingReviewer') {
+      updateOp = {
+        $addToSet: { readOnly_refs: user._id, pendingReviewer_refs: user._id },
+      }
     } else if (privileges === 'review') {
     } else if (privileges === 'review') {
       updateOp = {
       updateOp = {
         $addToSet: { reviewer_refs: user._id },
         $addToSet: { reviewer_refs: user._id },

+ 13 - 2
services/web/test/unit/src/Collaborators/CollaboratorsGetterTests.js

@@ -18,6 +18,7 @@ describe('CollaboratorsGetter', function () {
     this.readOnlyRef1 = new ObjectId()
     this.readOnlyRef1 = new ObjectId()
     this.readOnlyRef2 = new ObjectId()
     this.readOnlyRef2 = new ObjectId()
     this.pendingEditorRef = new ObjectId()
     this.pendingEditorRef = new ObjectId()
+    this.pendingReviewerRef = new ObjectId()
     this.readWriteRef1 = new ObjectId()
     this.readWriteRef1 = new ObjectId()
     this.readWriteRef2 = new ObjectId()
     this.readWriteRef2 = new ObjectId()
     this.reviewer1Ref = new ObjectId()
     this.reviewer1Ref = new ObjectId()
@@ -32,8 +33,10 @@ describe('CollaboratorsGetter', function () {
         this.readOnlyRef1,
         this.readOnlyRef1,
         this.readOnlyRef2,
         this.readOnlyRef2,
         this.pendingEditorRef,
         this.pendingEditorRef,
+        this.pendingReviewerRef,
       ],
       ],
       pendingEditor_refs: [this.pendingEditorRef],
       pendingEditor_refs: [this.pendingEditorRef],
+      pendingReviewer_refs: [this.pendingReviewerRef],
       collaberator_refs: [this.readWriteRef1, this.readWriteRef2],
       collaberator_refs: [this.readWriteRef1, this.readWriteRef2],
       reviewer_refs: [this.reviewer1Ref, this.reviewer2Ref],
       reviewer_refs: [this.reviewer1Ref, this.reviewer2Ref],
       tokenAccessReadAndWrite_refs: [this.readWriteTokenRef],
       tokenAccessReadAndWrite_refs: [this.readWriteTokenRef],
@@ -114,6 +117,12 @@ describe('CollaboratorsGetter', function () {
             source: 'invite',
             source: 'invite',
             pendingEditor: true,
             pendingEditor: true,
           },
           },
+          {
+            id: this.pendingReviewerRef.toString(),
+            privilegeLevel: 'readOnly',
+            source: 'invite',
+            pendingReviewer: true,
+          },
           {
           {
             id: this.readOnlyTokenRef.toString(),
             id: this.readOnlyTokenRef.toString(),
             privilegeLevel: 'readOnly',
             privilegeLevel: 'readOnly',
@@ -165,6 +174,7 @@ describe('CollaboratorsGetter', function () {
         this.readWriteRef1.toString(),
         this.readWriteRef1.toString(),
         this.readWriteRef2.toString(),
         this.readWriteRef2.toString(),
         this.pendingEditorRef.toString(),
         this.pendingEditorRef.toString(),
+        this.pendingReviewerRef.toString(),
         this.readWriteTokenRef.toString(),
         this.readWriteTokenRef.toString(),
         this.readOnlyTokenRef.toString(),
         this.readOnlyTokenRef.toString(),
         this.reviewer1Ref.toString(),
         this.reviewer1Ref.toString(),
@@ -186,6 +196,7 @@ describe('CollaboratorsGetter', function () {
         this.readWriteRef1.toString(),
         this.readWriteRef1.toString(),
         this.readWriteRef2.toString(),
         this.readWriteRef2.toString(),
         this.pendingEditorRef.toString(),
         this.pendingEditorRef.toString(),
+        this.pendingReviewerRef.toString(),
         this.reviewer1Ref.toString(),
         this.reviewer1Ref.toString(),
         this.reviewer2Ref.toString(),
         this.reviewer2Ref.toString(),
       ])
       ])
@@ -545,12 +556,12 @@ describe('CollaboratorsGetter', function () {
   })
   })
 
 
   describe('getInvitedPendingEditorCount', function () {
   describe('getInvitedPendingEditorCount', function () {
-    it('should return the count of pending editors', async function () {
+    it('should return the count of pending editors and reviewers', async function () {
       const count =
       const count =
         await this.CollaboratorsGetter.promises.getInvitedPendingEditorCount(
         await this.CollaboratorsGetter.promises.getInvitedPendingEditorCount(
           this.project._id
           this.project._id
         )
         )
-      expect(count).to.equal(1)
+      expect(count).to.equal(2)
     })
     })
   })
   })
 })
 })

+ 97 - 11
services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js

@@ -111,6 +111,7 @@ describe('CollaboratorsHandler', function () {
                 reviewer_refs: this.userId,
                 reviewer_refs: this.userId,
                 readOnly_refs: this.userId,
                 readOnly_refs: this.userId,
                 pendingEditor_refs: this.userId,
                 pendingEditor_refs: this.userId,
+                pendingReviewer_refs: this.userId,
                 tokenAccessReadOnly_refs: this.userId,
                 tokenAccessReadOnly_refs: this.userId,
                 tokenAccessReadAndWrite_refs: this.userId,
                 tokenAccessReadAndWrite_refs: this.userId,
                 archived: this.userId,
                 archived: this.userId,
@@ -155,6 +156,7 @@ describe('CollaboratorsHandler', function () {
                 reviewer_refs: this.userId,
                 reviewer_refs: this.userId,
                 readOnly_refs: this.userId,
                 readOnly_refs: this.userId,
                 pendingEditor_refs: this.userId,
                 pendingEditor_refs: this.userId,
+                pendingReviewer_refs: this.userId,
                 tokenAccessReadOnly_refs: this.userId,
                 tokenAccessReadOnly_refs: this.userId,
                 tokenAccessReadAndWrite_refs: this.userId,
                 tokenAccessReadAndWrite_refs: this.userId,
                 trashed: this.userId,
                 trashed: this.userId,
@@ -191,6 +193,7 @@ describe('CollaboratorsHandler', function () {
                 reviewer_refs: this.userId,
                 reviewer_refs: this.userId,
                 readOnly_refs: this.userId,
                 readOnly_refs: this.userId,
                 pendingEditor_refs: this.userId,
                 pendingEditor_refs: this.userId,
+                pendingReviewer_refs: this.userId,
                 tokenAccessReadOnly_refs: this.userId,
                 tokenAccessReadOnly_refs: this.userId,
                 tokenAccessReadAndWrite_refs: this.userId,
                 tokenAccessReadAndWrite_refs: this.userId,
                 archived: this.userId,
                 archived: this.userId,
@@ -278,6 +281,32 @@ describe('CollaboratorsHandler', function () {
           )
           )
         })
         })
       })
       })
+
+      describe('with pendingReviewer flag', function () {
+        it('should add them to the pending reviewer refs', async function () {
+          this.ProjectMock.expects('updateOne')
+            .withArgs(
+              {
+                _id: this.project._id,
+              },
+              {
+                $addToSet: {
+                  readOnly_refs: this.userId,
+                  pendingReviewer_refs: this.userId,
+                },
+              }
+            )
+            .chain('exec')
+            .resolves()
+          await this.CollaboratorsHandler.promises.addUserIdToProject(
+            this.project._id,
+            this.addingUserId,
+            this.userId,
+            'readOnly',
+            { pendingReviewer: true }
+          )
+        })
+      })
     })
     })
 
 
     describe('as readAndWrite', function () {
     describe('as readAndWrite', function () {
@@ -451,6 +480,7 @@ describe('CollaboratorsHandler', function () {
                 reviewer_refs: this.userId,
                 reviewer_refs: this.userId,
                 readOnly_refs: this.userId,
                 readOnly_refs: this.userId,
                 pendingEditor_refs: this.userId,
                 pendingEditor_refs: this.userId,
+                pendingReviewer_refs: this.userId,
                 tokenAccessReadOnly_refs: this.userId,
                 tokenAccessReadOnly_refs: this.userId,
                 tokenAccessReadAndWrite_refs: this.userId,
                 tokenAccessReadAndWrite_refs: this.userId,
                 archived: this.userId,
                 archived: this.userId,
@@ -549,6 +579,24 @@ describe('CollaboratorsHandler', function () {
         )
         )
         .chain('exec')
         .chain('exec')
         .resolves()
         .resolves()
+      this.ProjectMock.expects('updateMany')
+        .withArgs(
+          { pendingReviewer_refs: this.fromUserId },
+          {
+            $addToSet: { pendingReviewer_refs: this.toUserId },
+          }
+        )
+        .chain('exec')
+        .resolves()
+      this.ProjectMock.expects('updateMany')
+        .withArgs(
+          { pendingReviewer_refs: this.fromUserId },
+          {
+            $pull: { pendingReviewer_refs: this.fromUserId },
+          }
+        )
+        .chain('exec')
+        .resolves()
     })
     })
 
 
     describe('successfully', function () {
     describe('successfully', function () {
@@ -586,7 +634,7 @@ describe('CollaboratorsHandler', function () {
       this.ProjectMock.expects('updateOne')
       this.ProjectMock.expects('updateOne')
         .withArgs(
         .withArgs(
           {
           {
-            _id: this.projectId,
+            _id: this.project._id,
             $or: [
             $or: [
               { collaberator_refs: this.userId },
               { collaberator_refs: this.userId },
               { readOnly_refs: this.userId },
               { readOnly_refs: this.userId },
@@ -597,6 +645,7 @@ describe('CollaboratorsHandler', function () {
             $pull: {
             $pull: {
               collaberator_refs: this.userId,
               collaberator_refs: this.userId,
               pendingEditor_refs: this.userId,
               pendingEditor_refs: this.userId,
+              pendingReviewer_refs: this.userId,
               reviewer_refs: this.userId,
               reviewer_refs: this.userId,
             },
             },
             $addToSet: { readOnly_refs: this.userId },
             $addToSet: { readOnly_refs: this.userId },
@@ -605,7 +654,7 @@ describe('CollaboratorsHandler', function () {
         .chain('exec')
         .chain('exec')
         .resolves({ matchedCount: 1 })
         .resolves({ matchedCount: 1 })
       await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
       await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
-        this.projectId,
+        this.project._id,
         this.userId,
         this.userId,
         'readOnly'
         'readOnly'
       )
       )
@@ -615,7 +664,7 @@ describe('CollaboratorsHandler', function () {
       this.ProjectMock.expects('updateOne')
       this.ProjectMock.expects('updateOne')
         .withArgs(
         .withArgs(
           {
           {
-            _id: this.projectId,
+            _id: this.project._id,
             $or: [
             $or: [
               { collaberator_refs: this.userId },
               { collaberator_refs: this.userId },
               { readOnly_refs: this.userId },
               { readOnly_refs: this.userId },
@@ -628,13 +677,14 @@ describe('CollaboratorsHandler', function () {
               readOnly_refs: this.userId,
               readOnly_refs: this.userId,
               reviewer_refs: this.userId,
               reviewer_refs: this.userId,
               pendingEditor_refs: this.userId,
               pendingEditor_refs: this.userId,
+              pendingReviewer_refs: this.userId,
             },
             },
           }
           }
         )
         )
         .chain('exec')
         .chain('exec')
         .resolves({ matchedCount: 1 })
         .resolves({ matchedCount: 1 })
       await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
       await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
-        this.projectId,
+        this.project._id,
         this.userId,
         this.userId,
         'readAndWrite'
         'readAndWrite'
       )
       )
@@ -653,7 +703,7 @@ describe('CollaboratorsHandler', function () {
         this.ProjectMock.expects('updateOne')
         this.ProjectMock.expects('updateOne')
           .withArgs(
           .withArgs(
             {
             {
-              _id: this.projectId,
+              _id: this.project._id,
               $or: [
               $or: [
                 { collaberator_refs: this.userId },
                 { collaberator_refs: this.userId },
                 { readOnly_refs: this.userId },
                 { readOnly_refs: this.userId },
@@ -667,13 +717,14 @@ describe('CollaboratorsHandler', function () {
                 readOnly_refs: this.userId,
                 readOnly_refs: this.userId,
                 collaberator_refs: this.userId,
                 collaberator_refs: this.userId,
                 pendingEditor_refs: this.userId,
                 pendingEditor_refs: this.userId,
+                pendingReviewer_refs: this.userId,
               },
               },
             }
             }
           )
           )
           .chain('exec')
           .chain('exec')
           .resolves({ matchedCount: 1 })
           .resolves({ matchedCount: 1 })
         await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
         await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
-          this.projectId,
+          this.project._id,
           this.userId,
           this.userId,
           'review'
           'review'
         )
         )
@@ -695,7 +746,7 @@ describe('CollaboratorsHandler', function () {
         this.ProjectMock.expects('updateOne')
         this.ProjectMock.expects('updateOne')
           .withArgs(
           .withArgs(
             {
             {
-              _id: this.projectId,
+              _id: this.project._id,
               $or: [
               $or: [
                 { collaberator_refs: this.userId },
                 { collaberator_refs: this.userId },
                 { readOnly_refs: this.userId },
                 { readOnly_refs: this.userId },
@@ -709,13 +760,14 @@ describe('CollaboratorsHandler', function () {
                 readOnly_refs: this.userId,
                 readOnly_refs: this.userId,
                 collaberator_refs: this.userId,
                 collaberator_refs: this.userId,
                 pendingEditor_refs: this.userId,
                 pendingEditor_refs: this.userId,
+                pendingReviewer_refs: this.userId,
               },
               },
             }
             }
           )
           )
           .chain('exec')
           .chain('exec')
           .resolves({ matchedCount: 1 })
           .resolves({ matchedCount: 1 })
         await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
         await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
-          this.projectId,
+          this.project._id,
           this.userId,
           this.userId,
           'review'
           'review'
         )
         )
@@ -726,7 +778,7 @@ describe('CollaboratorsHandler', function () {
       this.ProjectMock.expects('updateOne')
       this.ProjectMock.expects('updateOne')
         .withArgs(
         .withArgs(
           {
           {
-            _id: this.projectId,
+            _id: this.project._id,
             $or: [
             $or: [
               { collaberator_refs: this.userId },
               { collaberator_refs: this.userId },
               { readOnly_refs: this.userId },
               { readOnly_refs: this.userId },
@@ -741,26 +793,60 @@ describe('CollaboratorsHandler', function () {
             $pull: {
             $pull: {
               collaberator_refs: this.userId,
               collaberator_refs: this.userId,
               reviewer_refs: this.userId,
               reviewer_refs: this.userId,
+              pendingReviewer_refs: this.userId,
             },
             },
           }
           }
         )
         )
         .chain('exec')
         .chain('exec')
         .resolves({ matchedCount: 1 })
         .resolves({ matchedCount: 1 })
       await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
       await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
-        this.projectId,
+        this.project._id,
         this.userId,
         this.userId,
         'readOnly',
         'readOnly',
         { pendingEditor: true }
         { pendingEditor: true }
       )
       )
     })
     })
 
 
+    it('sets a collaborator to read-only as a pendingReviewer', async function () {
+      this.ProjectMock.expects('updateOne')
+        .withArgs(
+          {
+            _id: this.project._id,
+            $or: [
+              { collaberator_refs: this.userId },
+              { readOnly_refs: this.userId },
+              { reviewer_refs: this.userId },
+            ],
+          },
+          {
+            $addToSet: {
+              readOnly_refs: this.userId,
+              pendingReviewer_refs: this.userId,
+            },
+            $pull: {
+              collaberator_refs: this.userId,
+              reviewer_refs: this.userId,
+              pendingEditor_refs: this.userId,
+            },
+          }
+        )
+        .chain('exec')
+        .resolves({ matchedCount: 1 })
+      await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
+        this.project._id,
+        this.userId,
+        'readOnly',
+        { pendingReviewer: true }
+      )
+    })
+
     it('throws a NotFoundError if the project or collaborator does not exist', async function () {
     it('throws a NotFoundError if the project or collaborator does not exist', async function () {
       this.ProjectMock.expects('updateOne')
       this.ProjectMock.expects('updateOne')
         .chain('exec')
         .chain('exec')
         .resolves({ matchedCount: 0 })
         .resolves({ matchedCount: 0 })
       await expect(
       await expect(
         this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
         this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
-          this.projectId,
+          this.project._id,
           this.userId,
           this.userId,
           'readAndWrite'
           'readAndWrite'
         )
         )

+ 1 - 1
services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.mjs

@@ -561,7 +561,7 @@ describe('CollaboratorsInviteHandler', function () {
           'editor-moved-to-pending',
           'editor-moved-to-pending',
           null,
           null,
           null,
           null,
-          { userId: this.userId.toString() }
+          { userId: this.userId.toString(), role: 'editor' }
         )
         )
         this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith(
         this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith(
           this.projectId,
           this.projectId,