Support for adding reviewer role (#22314)

* Support for adding reviewer role

* added collaboratorsGetter tests

* emit toggle-track-changes when reviewer is added

GitOrigin-RevId: 5b831102d561f5dbc4b23ab9f6dc63e3a87ea103
This commit is contained in:
Domagoj Kriskovic
2024-12-10 09:04:47 +00:00
committed by Copybot
parent 9ed3861c70
commit f39ca200b9
17 changed files with 171 additions and 4 deletions
@@ -186,6 +186,7 @@ async function canUserReadProject(userId, projectId, token) {
PrivilegeLevels.OWNER,
PrivilegeLevels.READ_AND_WRITE,
PrivilegeLevels.READ_ONLY,
PrivilegeLevels.REVIEW,
].includes(privilegeLevel)
}
@@ -2,6 +2,7 @@ const PrivilegeLevels = {
NONE: false,
READ_ONLY: 'readOnly',
READ_AND_WRITE: 'readAndWrite',
REVIEW: 'review',
OWNER: 'owner',
}
@@ -61,6 +61,7 @@ async function getMemberIdsWithPrivilegeLevels(projectId) {
tokenAccessReadAndWrite_refs: 1,
publicAccesLevel: 1,
pendingEditor_refs: 1,
reviewer_refs: 1,
})
if (!project) {
throw new Errors.NotFoundError(`no project found with id ${projectId}`)
@@ -72,7 +73,8 @@ async function getMemberIdsWithPrivilegeLevels(projectId) {
project.tokenAccessReadAndWrite_refs,
project.tokenAccessReadOnly_refs,
project.publicAccesLevel,
project.pendingEditor_refs
project.pendingEditor_refs,
project.reviewer_refs
)
return memberIds
}
@@ -324,7 +326,8 @@ function _getMemberIdsWithPrivilegeLevelsFromFields(
tokenAccessIds,
tokenAccessReadOnlyIds,
publicAccessLevel,
pendingEditorIds
pendingEditorIds,
reviewerIds
) {
const members = []
members.push({
@@ -365,6 +368,13 @@ function _getMemberIdsWithPrivilegeLevelsFromFields(
})
}
}
for (const memberId of reviewerIds || []) {
members.push({
id: memberId.toString(),
privilegeLevel: PrivilegeLevels.REVIEW,
source: Sources.INVITE,
})
}
return members
}
@@ -10,6 +10,7 @@ const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
const CollaboratorsGetter = require('./CollaboratorsGetter')
const Errors = require('../Errors/Errors')
const TpdsUpdateSender = require('../ThirdPartyDataStore/TpdsUpdateSender')
const EditorRealTimeController = require('../Editor/EditorRealTimeController')
module.exports = {
userIsTokenMember: callbackify(userIsTokenMember),
@@ -48,6 +49,7 @@ async function removeUserFromProject(projectId, userId) {
$set: { archived },
$pull: {
collaberator_refs: userId,
reviewer_refs: userId,
readOnly_refs: userId,
pendingEditor_refs: userId,
tokenAccessReadOnly_refs: userId,
@@ -63,6 +65,7 @@ async function removeUserFromProject(projectId, userId) {
$pull: {
collaberator_refs: userId,
readOnly_refs: userId,
reviewer_refs: userId,
pendingEditor_refs: userId,
tokenAccessReadOnly_refs: userId,
tokenAccessReadAndWrite_refs: userId,
@@ -109,6 +112,8 @@ async function addUserIdToProject(
name: 1,
collaberator_refs: 1,
readOnly_refs: 1,
reviewer_refs: 1,
track_changes: 1,
})
let level
let existingUsers = project.collaberator_refs || []
@@ -132,6 +137,9 @@ async function addUserIdToProject(
{ privileges: 'readOnly', userId, projectId, pendingEditor },
'adding user'
)
} else if (privilegeLevel === PrivilegeLevels.REVIEW) {
level = { reviewer_refs: userId }
logger.debug({ privileges: 'reviewer', userId, projectId }, 'adding user')
} else {
throw new Error(`unknown privilegeLevel: ${privilegeLevel}`)
}
@@ -140,7 +148,46 @@ async function addUserIdToProject(
ContactManager.addContact(addingUserId, userId, () => {})
}
await Project.updateOne({ _id: projectId }, { $addToSet: level }).exec()
if (privilegeLevel === PrivilegeLevels.REVIEW) {
const trackChanges =
typeof project.track_changes === 'object' ? project.track_changes : {}
trackChanges[userId] = true
if (project.track_changes === true) {
// track changes are enabled for all
// we need to convert it to explicit format
const members =
await CollaboratorsGetter.promises.getMemberIdsWithPrivilegeLevels(
project
)
for (const { id, privilegeLevel } of members) {
if (
[
PrivilegeLevels.OWNER,
PrivilegeLevels.READ_AND_WRITE,
PrivilegeLevels.REVIEW,
].includes(privilegeLevel)
) {
trackChanges[id] = true
}
}
}
await Project.updateOne(
{ _id: projectId },
{ track_changes: trackChanges, $addToSet: level }
).exec()
EditorRealTimeController.emitToRoom(
projectId,
'toggle-track-changes',
trackChanges
)
} else {
await Project.updateOne({ _id: projectId }, { $addToSet: level }).exec()
}
// Ensure there is a dedicated folder for this "new" project.
await TpdsUpdateSender.promises.createProject({
@@ -353,6 +353,7 @@ const _ProjectController = {
'default-visual-for-beginners',
'hotjar',
'ai-add-on',
'reviewer-role',
'papers-integration',
].filter(Boolean)
@@ -25,6 +25,7 @@ import LimitationsManager from '../Subscription/LimitationsManager.js'
const orderedPrivilegeLevels = [
PrivilegeLevels.NONE,
PrivilegeLevels.READ_ONLY,
PrivilegeLevels.REVIEW,
PrivilegeLevels.READ_AND_WRITE,
PrivilegeLevels.OWNER,
]
+1
View File
@@ -38,6 +38,7 @@ const ProjectSchema = new Schema(
active: { type: Boolean, default: true },
owner_ref: { type: ObjectId, ref: 'User' },
collaberator_refs: [{ type: ObjectId, ref: 'User' }],
reviewer_refs: [{ type: ObjectId, ref: 'User' }],
readOnly_refs: [{ type: ObjectId, ref: 'User' }],
pendingEditor_refs: [{ type: ObjectId, ref: 'User' }],
rootDoc_id: { type: ObjectId },
@@ -175,6 +175,7 @@
"can_link_institution_email_acct_to_institution_acct": "",
"can_link_your_institution_acct_2": "",
"can_now_relink_dropbox": "",
"can_review": "",
"can_view": "",
"cancel": "",
"cancel_add_on": "",
@@ -1289,6 +1290,7 @@
"revert_pending_plan_change": "",
"review": "",
"review_your_peers_work": "",
"reviewer": "",
"revoke": "",
"revoke_invite": "",
"right": "",
@@ -21,6 +21,12 @@ const permissionsMap: DeepReadonly<Record<PermissionsLevel, Permissions>> = {
admin: false,
comment: true,
},
review: {
read: true,
write: true,
admin: false,
comment: true,
},
readAndWrite: {
read: true,
write: true,
@@ -38,12 +44,14 @@ const permissionsMap: DeepReadonly<Record<PermissionsLevel, Permissions>> = {
const anonymousPermissionsMap: typeof permissionsMap = {
readOnly: { ...permissionsMap.readOnly, comment: false },
readAndWrite: { ...permissionsMap.readAndWrite, comment: false },
review: { ...permissionsMap.review, comment: false },
owner: { ...permissionsMap.owner, comment: false },
}
const linkSharingWarningPermissionsMap: typeof permissionsMap = {
readOnly: { ...permissionsMap.readOnly, comment: false },
readAndWrite: permissionsMap.readAndWrite,
review: permissionsMap.review,
owner: permissionsMap.owner,
}
@@ -5,4 +5,4 @@ export type Permissions = {
comment: boolean
}
export type PermissionsLevel = 'owner' | 'readAndWrite' | 'readOnly'
export type PermissionsLevel = 'owner' | 'readAndWrite' | 'review' | 'readOnly'
@@ -11,6 +11,9 @@ export default function MemberPrivileges({ privileges }) {
case 'readOnly':
return t('read_only')
case 'review':
return t('can_review')
default:
return null
}
@@ -14,6 +14,7 @@ import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import OLButton from '@/features/ui/components/ol/ol-button'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export default function AddCollaborators({ readOnly }) {
const [privileges, setPrivileges] = useState('readAndWrite')
@@ -173,6 +174,9 @@ export default function AddCollaborators({ readOnly }) {
{t('can_edit')}
</option>
<option value="readOnly">{t('can_view')}</option>
{isSplitTestEnabled('reviewer-role') && (
<option value="review">{t('can_review')}</option>
)}
</OLFormSelect>
<span>&nbsp;&nbsp;</span>
<ClickableElementEnhancer
@@ -237,6 +237,7 @@ function SelectPrivilege({
(): Privilege[] => [
{ key: 'owner', label: t('make_owner') },
{ key: 'readAndWrite', label: t('editor') },
{ key: 'review', label: t('reviewer') },
{ key: 'readOnly', label: t('viewer') },
{ key: 'removeAccess', label: t('remove_access') },
],
@@ -11,6 +11,9 @@ export default function MemberPrivileges({ privileges }) {
case 'readOnly':
return t('read_only')
case 'review':
return t('can_review')
default:
return null
}
+1
View File
@@ -239,6 +239,7 @@
"can_link_institution_email_to_login": "You can link your <b>__email__</b> <b>__appName__</b> account to your <b>__institutionName__</b> account, which will allow you to log in to <b>__appName__</b> through your institution and will reconfirm your institutional email address.",
"can_link_your_institution_acct_2": "You can now <0>link</0> your <0>__appName__</0> account to your <0>__institutionName__</0> institutional account.",
"can_now_relink_dropbox": "You can now <0>relink your Dropbox account</0>.",
"can_review": "Can review",
"can_view": "Can view",
"cancel": "Cancel",
"cancel_add_on": "Cancel add-on",
@@ -20,6 +20,8 @@ describe('CollaboratorsGetter', function () {
this.pendingEditorRef = new ObjectId()
this.readWriteRef1 = new ObjectId()
this.readWriteRef2 = new ObjectId()
this.reviewer1Ref = new ObjectId()
this.reviewer2Ref = new ObjectId()
this.readOnlyTokenRef = new ObjectId()
this.readWriteTokenRef = new ObjectId()
this.nonMemberRef = new ObjectId()
@@ -33,6 +35,7 @@ describe('CollaboratorsGetter', function () {
],
pendingEditor_refs: [this.pendingEditorRef],
collaberator_refs: [this.readWriteRef1, this.readWriteRef2],
reviewer_refs: [this.reviewer1Ref, this.reviewer2Ref],
tokenAccessReadAndWrite_refs: [this.readWriteTokenRef],
tokenAccessReadOnly_refs: [this.readOnlyTokenRef],
publicAccesLevel: 'tokenBased',
@@ -121,6 +124,16 @@ describe('CollaboratorsGetter', function () {
privilegeLevel: 'readAndWrite',
source: 'token',
},
{
id: this.reviewer1Ref.toString(),
privilegeLevel: 'review',
source: 'invite',
},
{
id: this.reviewer2Ref.toString(),
privilegeLevel: 'review',
source: 'invite',
},
])
})
})
@@ -154,6 +167,8 @@ describe('CollaboratorsGetter', function () {
this.pendingEditorRef.toString(),
this.readWriteTokenRef.toString(),
this.readOnlyTokenRef.toString(),
this.reviewer1Ref.toString(),
this.reviewer2Ref.toString(),
])
})
})
@@ -171,6 +186,8 @@ describe('CollaboratorsGetter', function () {
this.readWriteRef1.toString(),
this.readWriteRef2.toString(),
this.pendingEditorRef.toString(),
this.reviewer1Ref.toString(),
this.reviewer2Ref.toString(),
])
})
})
@@ -189,6 +206,9 @@ describe('CollaboratorsGetter', function () {
this.UserGetter.promises.getUser
.withArgs(this.readWriteTokenRef.toString())
.resolves({ _id: this.readWriteTokenRef })
this.UserGetter.promises.getUser
.withArgs(this.reviewer1Ref.toString())
.resolves({ _id: this.reviewer1Ref })
})
it('should return an array of invited members with their privilege levels', async function () {
@@ -199,6 +219,7 @@ describe('CollaboratorsGetter', function () {
expect(result).to.have.deep.members([
{ user: { _id: this.readOnlyRef1 }, privilegeLevel: 'readOnly' },
{ user: { _id: this.readWriteRef2 }, privilegeLevel: 'readAndWrite' },
{ user: { _id: this.reviewer1Ref }, privilegeLevel: 'review' },
])
})
})
@@ -213,6 +234,15 @@ describe('CollaboratorsGetter', function () {
expect(level).to.equal('readOnly')
})
it('should return review privilege level', async function () {
const level =
await this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
this.reviewer1Ref,
this.project._id
)
expect(level).to.equal('review')
})
it('should return false if the member has no privilege level', async function () {
const level =
await this.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
@@ -352,15 +382,21 @@ describe('CollaboratorsGetter', function () {
_id: this.readWriteRef1,
email: 'readwrite@example.com',
}
this.reviewUser = {
_id: this.reviewer1Ref,
email: 'review@example.com',
}
this.members = [
{ user: this.owningUser, privilegeLevel: 'owner' },
{ user: this.readWriteUser, privilegeLevel: 'readAndWrite' },
{ user: this.reviewUser, privilegeLevel: 'review' },
]
this.views = {
owner: this.owningUser,
ownerFeatures: this.owningUser.features,
members: [
{ _id: this.readWriteUser._id, email: this.readWriteUser.email },
{ _id: this.reviewUser._id, email: this.reviewUser.email },
],
}
this.UserGetter.promises.getUser
@@ -369,6 +405,9 @@ describe('CollaboratorsGetter', function () {
this.UserGetter.promises.getUser
.withArgs(this.readWriteUser._id.toString())
.resolves(this.readWriteUser)
this.UserGetter.promises.getUser
.withArgs(this.reviewUser._id.toString())
.resolves(this.reviewUser)
this.ProjectEditorHandler.buildOwnerAndMembersViews.returns(this.views)
this.result =
await this.CollaboratorsGetter.promises.getAllInvitedMembers(
@@ -65,8 +65,10 @@ describe('CollaboratorsHandler', function () {
this.CollaboratorsGetter = {
promises: {
dangerouslyGetAllProjectsUserIsMemberOf: sinon.stub(),
getMemberIdsWithPrivilegeLevels: sinon.stub().resolves([]),
},
}
this.EditorRealTimeController = { emitToRoom: sinon.stub() }
this.CollaboratorsHandler = SandboxedModule.require(MODULE_PATH, {
requires: {
'../User/UserGetter': this.UserGetter,
@@ -76,6 +78,7 @@ describe('CollaboratorsHandler', function () {
'../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender,
'../Project/ProjectGetter': this.ProjectGetter,
'../Project/ProjectHelper': this.ProjectHelper,
'../Editor/EditorRealTimeController': this.EditorRealTimeController,
'./CollaboratorsGetter': this.CollaboratorsGetter,
},
})
@@ -105,6 +108,7 @@ describe('CollaboratorsHandler', function () {
{
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
tokenAccessReadOnly_refs: this.userId,
@@ -148,6 +152,7 @@ describe('CollaboratorsHandler', function () {
},
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
tokenAccessReadOnly_refs: this.userId,
@@ -183,6 +188,7 @@ describe('CollaboratorsHandler', function () {
{
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
tokenAccessReadOnly_refs: this.userId,
@@ -302,6 +308,43 @@ describe('CollaboratorsHandler', function () {
})
})
describe('as reviewer', function () {
beforeEach(async function () {
this.ProjectMock.expects('updateOne')
.withArgs(
{
_id: this.project._id,
},
{
track_changes: { [this.userId]: true },
$addToSet: { reviewer_refs: this.userId },
}
)
.chain('exec')
.resolves()
await this.CollaboratorsHandler.promises.addUserIdToProject(
this.project._id,
this.addingUserId,
this.userId,
'review'
)
})
it('should update the client with new track changes settings', function () {
return this.EditorRealTimeController.emitToRoom
.calledWith(this.project._id, 'toggle-track-changes', {
[this.userId]: true,
})
.should.equal(true)
})
it('should flush the project to the TPDS', function () {
expect(
this.TpdsProjectFlusher.promises.flushProjectToTpds
).to.have.been.calledWith(this.project._id)
})
})
describe('with invalid privilegeLevel', function () {
it('should call the callback with an error', async function () {
await expect(
@@ -405,6 +448,7 @@ describe('CollaboratorsHandler', function () {
{
$pull: {
collaberator_refs: this.userId,
reviewer_refs: this.userId,
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
tokenAccessReadOnly_refs: this.userId,