Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to swap a project member for another across projects #2792

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 158 additions & 1 deletion src/components/project/project-member/project-member.repository.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { Injectable } from '@nestjs/common';
import { Node, node, Query, relation } from 'cypher-query-builder';
import { difference, union } from 'lodash';
import { DateTime } from 'luxon';
import { ID, Session, UnsecuredDto } from '../../../common';
import {
generateId,
ID,
InputException,
MaybeAsync,
Role,
Session,
UnsecuredDto,
} from '../../../common';
import { DatabaseService, DtoRepository } from '../../../core';
import {
ACTIVE,
deleteBaseNode,
matchPropsAndProjectSensAndScopedRoles,
merge,
oncePerProject,
paginate,
property,
requestingUser,
sorting,
variable,
} from '../../../core/database/query';
import { User } from '../../user';
import { UserRepository } from '../../user/user.repository';
import {
CreateProjectMember,
Expand Down Expand Up @@ -93,6 +105,151 @@ export class ProjectMemberRepository extends DtoRepository<
.first();
}

async assertValidRoles(
roles: Role[] | undefined,
forUser: () => MaybeAsync<User>,
) {
if (!roles || roles.length === 0) {
return;
}
const user = await forUser();
const availableRoles = user.roles.value ?? [];
const forbiddenRoles = difference(roles, availableRoles);
if (forbiddenRoles.length) {
const forbiddenRolesStr = forbiddenRoles.join(', ');
throw new InputException(
`Role(s) ${forbiddenRolesStr} cannot be assigned to this project member`,
'input.roles',
);
}
}

async swapMembers(oldMemberId: ID, newMember: User) {
const projectsRoles = await this.db
.query()
.comment('swapMembers: get projects oldMember is apart of')
.matchNode('oldUser', 'User', { id: oldMemberId })
.match([
[
node('project', 'Project'),
relation('out', '', 'member', ACTIVE),
node('projectMember'),
relation('out', '', 'user'),
node('oldUser'),
],
[
node('projectMember'),
relation('out', '', 'roles', ACTIVE),
node('rolesProp', 'Property'),
],
])
.return<{
projectId: ID;
oldProjectMemberId: ID;
oldMemberProjectRoles: Role[];
}>(
'project.id as projectId, projectMember.id as oldProjectMemberId, rolesProp.value as oldMemberProjectRoles',
)
.run();

const memberRoles = union(
projectsRoles.map((pRole) => pRole.oldMemberProjectRoles).flat(),
);

await this.assertValidRoles(memberRoles, () => {
return newMember;
});

const projectsRolesIds = projectsRoles
? await Promise.all(
projectsRoles.map(async (projRole) => ({
memberId: await generateId(),
roles: projRole.oldMemberProjectRoles,
projectId: projRole.projectId,
oldProjectMemberId: projRole.oldProjectMemberId,
})),
)
: null;
if (!projectsRolesIds) return;

await this.db
.query()
.comment('swapMember: create new project members')
.unwind(projectsRolesIds, 'projectRolesId')
.subQuery('projectRolesId', (sub) =>
sub
.create([
[
node('newProjectMember', 'ProjectMember:BaseNode', {
createdAt: DateTime.local(),
id: variable('projectRolesId.memberId'),
}),
],
...property(
'roles',
variable('projectRolesId.roles'),
'newProjectMember',
),
...property('modifiedAt', DateTime.local(), 'newProjectMember'),
])
.return('newProjectMember.id as newMemberId'),
)
.return<{ newMemberId: ID }>('newMemberId')
.run();

await this.db
.query()
.comment('connect new projectMember nodes to user and project')
.unwind(projectsRolesIds, 'projectRolesId')
.subQuery('projectRolesId', (sub) =>
sub
.match([
[
node('project', 'Project', {
id: variable('projectRolesId.projectId'),
}),
],
[
node('projectMember', 'ProjectMember', {
id: variable('projectRolesId.memberId'),
}),
],
[node('newMember', 'User', { id: newMember.id })],
])
.create([
node('project'),
relation('out', '', 'member', {
active: true,
createdAt: DateTime.local(),
}),
node('projectMember'),
relation('out', '', 'user', {
active: true,
createdAt: DateTime.local(),
}),
node('newMember'),
])
.return('newMember.id as newUserId'),
)
.return('newUserId')
.run();

await this.db
.query()
.comment('deleting old project member...')
.unwind(projectsRolesIds, 'projectRolesId')
.subQuery('projectRolesId', (sub) =>
sub
.matchNode('projectMember', {
id: variable('projectRolesId.oldProjectMemberId'),
})
.apply(deleteBaseNode('projectMember'))
.return('*'),
)
.return('*')
.run();
}

protected hydrate(session: Session) {
return (query: Query) =>
query
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ export class ProjectMemberResolver {
return { projectMember };
}

@Mutation(() => Boolean, {
description:
'Swap out the oldProjectMember with newProjectMember in all projects that oldProjectMember is associated with.',
})
async swapProjectMember(
@LoggedInSession() session: Session,
@IdArg({ name: 'oldMemberId' }) oldMemberId: ID,
@IdArg({ name: 'newMemberId' }) newMemberId: ID,
): Promise<boolean> {
await this.service.swapMembers(session, oldMemberId, newMemberId);
return true;
}

@Mutation(() => DeleteProjectMemberOutput, {
description: 'Delete a project member',
})
Expand Down
34 changes: 8 additions & 26 deletions src/components/project/project-member/project-member.service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { node, Query, relation } from 'cypher-query-builder';
import { RelationDirection } from 'cypher-query-builder/dist/typings/clauses/relation-pattern';
import { difference } from 'lodash';
import { DateTime } from 'luxon';
import {
DuplicateException,
generateId,
ID,
InputException,
isIdLike,
MaybeAsync,
NotFoundException,
ObjectView,
ServerException,
Expand All @@ -27,9 +24,8 @@ import {
} from '../../../core';
import { ACTIVE } from '../../../core/database/query';
import { mapListResults } from '../../../core/database/results';
import { Role } from '../../authorization';
import { AuthorizationService } from '../../authorization/authorization.service';
import { User, UserService } from '../../user';
import { UserService } from '../../user';
import { IProject } from '../dto';
import { ProjectService } from '../project.service';
import {
Expand Down Expand Up @@ -105,7 +101,7 @@ export class ProjectMemberService {
const createdAt = DateTime.local();
await this.repo.verifyRelationshipEligibility(projectId, userId);

await this.assertValidRoles(input.roles, () =>
await this.repo.assertValidRoles(input.roles, () =>
this.userService.readOne(userId, session),
);

Expand Down Expand Up @@ -179,13 +175,18 @@ export class ProjectMemberService {
};
}

async swapMembers(session: Session, oldMemberId: ID, newMemberId: ID) {
const newMember = await this.userService.readOne(newMemberId, session);
await this.repo.swapMembers(oldMemberId, newMember);
}

async update(
input: UpdateProjectMember,
session: Session,
): Promise<ProjectMember> {
const object = await this.readOne(input.id, session);

await this.assertValidRoles(input.roles, () => {
await this.repo.assertValidRoles(input.roles, () => {
const user = object.user.value;
if (!user) {
throw new UnauthorizedException(
Expand All @@ -205,25 +206,6 @@ export class ProjectMemberService {
return await this.readOne(input.id, session);
}

private async assertValidRoles(
roles: Role[] | undefined,
forUser: () => MaybeAsync<User>,
) {
if (!roles || roles.length === 0) {
return;
}
const user = await forUser();
const availableRoles = user.roles.value ?? [];
const forbiddenRoles = difference(roles, availableRoles);
if (forbiddenRoles.length) {
const forbiddenRolesStr = forbiddenRoles.join(', ');
throw new InputException(
`Role(s) ${forbiddenRolesStr} cannot be assigned to this project member`,
'input.roles',
);
}
}

async delete(id: ID, session: Session): Promise<void> {
const object = await this.readOne(id, session);

Expand Down
Loading