diff --git a/app/graphql/mutations/namespaces/members/bulk_invite.rb b/app/graphql/mutations/namespaces/members/bulk_invite.rb new file mode 100644 index 00000000..132d9856 --- /dev/null +++ b/app/graphql/mutations/namespaces/members/bulk_invite.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Namespaces + module Members + class BulkInvite < BaseMutation + description 'Invite multiple new members to a namespace.' + + field :namespace_members, [Types::NamespaceMemberType], + description: 'The newly created namespace members' + + argument :namespace_id, Types::GlobalIdType[::Namespace], + description: 'The id of the namespace which these members will belong to' + argument :user_ids, [Types::GlobalIdType[::User]], description: 'The ids of the users to invite' + + def resolve(namespace_id:, user_ids:) + namespace = SagittariusSchema.object_from_id(namespace_id) + users = user_ids.map { |id| SagittariusSchema.object_from_id(id) } + + if namespace.nil? + return { namespace_members: nil, + errors: [create_error(:namespace_not_found, 'Invalid namespace')] } + end + if users.any?(&:nil?) + return { namespace_members: nil, + errors: [create_error(:user_not_found, 'Invalid user')] } + end + + ::Namespaces::Members::BulkInviteService.new( + current_authentication, + namespace, + users + ).execute.to_mutation_response(success_key: :namespace_members) + end + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 47964c83..c0a46099 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -10,6 +10,7 @@ class MutationType < Types::BaseObject mount_mutation Mutations::Ai::GenerateFlow mount_mutation Mutations::Echo mount_mutation Mutations::Namespaces::Members::AssignRoles + mount_mutation Mutations::Namespaces::Members::BulkInvite mount_mutation Mutations::Namespaces::Members::Delete mount_mutation Mutations::Namespaces::Members::Invite mount_mutation Mutations::Namespaces::Projects::AssignRuntimes diff --git a/app/services/namespaces/members/bulk_invite_service.rb b/app/services/namespaces/members/bulk_invite_service.rb new file mode 100644 index 00000000..5a97d2ff --- /dev/null +++ b/app/services/namespaces/members/bulk_invite_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Namespaces + module Members + class BulkInviteService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :namespace, :users + + def initialize(current_authentication, namespace, users) + @current_authentication = current_authentication + @namespace = namespace + @users = users + end + + def execute + transactional do |transaction| + namespace_members = users.map do |user| + response = InviteService.new(current_authentication, namespace, user).execute + transaction.rollback_and_return!(response) if response.error? + + response.payload + end + + ServiceResponse.success(message: 'Namespace members invited', payload: namespace_members) + end + end + end + end +end diff --git a/docs/graphql/mutation/namespacesmembersbulkinvite.md b/docs/graphql/mutation/namespacesmembersbulkinvite.md new file mode 100644 index 00000000..77900e63 --- /dev/null +++ b/docs/graphql/mutation/namespacesmembersbulkinvite.md @@ -0,0 +1,21 @@ +--- +title: namespacesMembersBulkInvite +--- + +Invite multiple new members to a namespace. + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `namespaceId` | [`NamespaceID!`](../scalar/namespaceid.md) | The id of the namespace which these members will belong to | +| `userIds` | [`[UserID!]!`](../scalar/userid.md) | The ids of the users to invite | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. | +| `namespaceMembers` | [`[NamespaceMember!]`](../object/namespacemember.md) | The newly created namespace members | diff --git a/spec/requests/graphql/mutation/namespace/members/bulk_invite_mutation_spec.rb b/spec/requests/graphql/mutation/namespace/members/bulk_invite_mutation_spec.rb new file mode 100644 index 00000000..883c0e6b --- /dev/null +++ b/spec/requests/graphql/mutation/namespace/members/bulk_invite_mutation_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'namespacesMembersBulkInvite Mutation' do + include GraphqlHelpers + + subject(:mutate!) { post_graphql mutation, variables: variables, current_user: current_user } + + let(:mutation) do + <<~QUERY + mutation($input: NamespacesMembersBulkInviteInput!) { + namespacesMembersBulkInvite(input: $input) { + #{error_query} + namespaceMembers { + id + user { + id + } + namespace { + id + } + } + } + } + QUERY + end + + let(:namespace) { create(:namespace) } + let(:users) { create_list(:user, 2) } + let(:input) do + { + namespaceId: namespace.to_global_id.to_s, + userIds: users.map { |user| user.to_global_id.to_s }, + } + end + let(:variables) { { input: input } } + let(:current_user) { create(:user) } + + context 'when user has permission' do + before do + create(:namespace_member, namespace: namespace, user: current_user) + stub_allowed_ability(NamespacePolicy, :invite_member, user: current_user, subject: namespace) + end + + it 'creates all namespace members and their audit events' do + mutate! + + member_ids = graphql_data_at(:namespaces_members_bulk_invite, :namespace_members, :id) + namespace_members = member_ids.map { |id| SagittariusSchema.object_from_id(id) } + + expect(namespace_members.map(&:user)).to match_array(users) + expect(namespace_members.map(&:namespace)).to all(eq(namespace)) + expect( + AuditEvent.where( + action_type: :namespace_member_invited, + entity_type: 'NamespaceMember', + entity_id: namespace_members.map(&:id) + ).count + ).to eq(2) + end + + it 'rolls back all invitations when one user is already a member' do + create(:namespace_member, namespace: namespace, user: users.last) + + expect { mutate! }.not_to change { namespace.namespace_members.count } + + expect(graphql_data_at(:namespaces_members_bulk_invite, :namespace_members)).to be_nil + expect( + graphql_data_at(:namespaces_members_bulk_invite, :errors, :details) + ).to include([{ 'attribute' => 'namespace', 'type' => 'taken' }]) + end + end + + context 'when user does not have permission' do + it 'returns an error without creating members' do + expect { mutate! }.not_to change { NamespaceMember.count } + + expect(graphql_data_at(:namespaces_members_bulk_invite, :namespace_members)).to be_nil + expect( + graphql_data_at(:namespaces_members_bulk_invite, :errors, :error_code) + ).to include('MISSING_PERMISSION') + end + end +end diff --git a/spec/services/namespaces/members/bulk_invite_service_spec.rb b/spec/services/namespaces/members/bulk_invite_service_spec.rb new file mode 100644 index 00000000..9d70adaf --- /dev/null +++ b/spec/services/namespaces/members/bulk_invite_service_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Namespaces::Members::BulkInviteService do + subject(:service_response) do + described_class.new(create_authentication(current_user), namespace, users).execute + end + + let(:namespace) { create(:namespace) } + let(:users) { create_list(:user, 2) } + let(:current_user) { create(:user) } + + context 'when user has permission' do + before do + stub_allowed_ability(NamespacePolicy, :invite_member, user: current_user, subject: namespace) + end + + it 'creates an audit event for every invited member' do + expect { service_response }.to change { + AuditEvent.where(action_type: :namespace_member_invited).count + }.by(users.count) + + events = AuditEvent.where( + action_type: :namespace_member_invited, + entity_type: 'NamespaceMember' + ) + + expect(events.pluck(:entity_id)).to match_array(service_response.payload.map(&:id)) + expect(events).to all( + have_attributes( + author_id: current_user.id, + details: {}, + target_id: namespace.id, + target_type: 'Namespace' + ) + ) + end + end +end