From 6b0c5e6ccbc849ecc65a8fe3a704b06407e54f39 Mon Sep 17 00:00:00 2001
From: Lee Tickett <lee@tickett.net>
Date: Mon, 8 Nov 2021 11:28:20 +0000
Subject: [PATCH] Add issues set crm contacts service and graphql mutation

Inroduce a new graphql mutation for setting/adding/removing customer
relations contacts to/from issues (and the required service to
support it)

Changelog: added
---
 .../mutations/issues/set_crm_contacts.rb      |  48 ++++++
 app/graphql/types/mutation_type.rb            |   1 +
 .../customer_relations/issue_contact.rb       |   2 +-
 app/policies/issue_policy.rb                  |   7 +
 .../issues/set_crm_contacts_service.rb        |  90 ++++++++++
 doc/api/graphql/reference/index.md            |  22 +++
 locale/gitlab.pot                             |   2 +-
 .../mutations/issues/set_crm_contacts_spec.rb | 161 +++++++++++++++++
 .../issues/set_crm_contacts_service_spec.rb   | 162 ++++++++++++++++++
 9 files changed, 493 insertions(+), 2 deletions(-)
 create mode 100644 app/graphql/mutations/issues/set_crm_contacts.rb
 create mode 100644 app/services/issues/set_crm_contacts_service.rb
 create mode 100644 spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb
 create mode 100644 spec/services/issues/set_crm_contacts_service_spec.rb

diff --git a/app/graphql/mutations/issues/set_crm_contacts.rb b/app/graphql/mutations/issues/set_crm_contacts.rb
new file mode 100644
index 0000000000000..7a9e6237eaa18
--- /dev/null
+++ b/app/graphql/mutations/issues/set_crm_contacts.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Mutations
+  module Issues
+    class SetCrmContacts < Base
+      graphql_name 'IssueSetCrmContacts'
+
+      argument :crm_contact_ids,
+               [::Types::GlobalIDType[::CustomerRelations::Contact]],
+               required: true,
+               description: 'Customer relations contact IDs to set. Replaces existing contacts by default.'
+
+      argument :operation_mode,
+               Types::MutationOperationModeEnum,
+               required: false,
+               description: 'Changes the operation mode. Defaults to REPLACE.'
+
+      def resolve(project_path:, iid:, crm_contact_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace])
+        issue = authorized_find!(project_path: project_path, iid: iid)
+        project = issue.project
+        raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, project.group, default_enabled: :yaml)
+
+        crm_contact_ids = crm_contact_ids.compact.map do |crm_contact_id|
+          raise Gitlab::Graphql::Errors::ArgumentError, "Contact #{crm_contact_id} is invalid." unless crm_contact_id.respond_to?(:model_id)
+
+          crm_contact_id.model_id.to_i
+        end
+
+        attribute_name = case operation_mode
+                         when Types::MutationOperationModeEnum.enum[:append]
+                           :add_crm_contact_ids
+                         when Types::MutationOperationModeEnum.enum[:remove]
+                           :remove_crm_contact_ids
+                         else
+                           :crm_contact_ids
+                         end
+
+        response = ::Issues::SetCrmContactsService.new(project: project, current_user: current_user, params: { attribute_name => crm_contact_ids })
+          .execute(issue)
+
+        {
+          issue: issue,
+          errors: response.errors
+        }
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 4104774c1dee1..7be995a40b217 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -49,6 +49,7 @@ class MutationType < BaseObject
     mount_mutation Mutations::Environments::CanaryIngress::Update
     mount_mutation Mutations::Issues::Create
     mount_mutation Mutations::Issues::SetAssignees
+    mount_mutation Mutations::Issues::SetCrmContacts
     mount_mutation Mutations::Issues::SetConfidential
     mount_mutation Mutations::Issues::SetLocked
     mount_mutation Mutations::Issues::SetDueDate
diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb
index ba73b685906ca..98faf8d664450 100644
--- a/app/models/customer_relations/issue_contact.rb
+++ b/app/models/customer_relations/issue_contact.rb
@@ -15,6 +15,6 @@ def contact_belongs_to_issue_group
     return unless issue&.project&.namespace_id
     return if contact.group_id == issue.project.namespace_id
 
-    errors.add(:base, _('The contact does not belong to the same group as the issue.'))
+    errors.add(:base, _('The contact does not belong to the same group as the issue'))
   end
 end
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 575e532c615d6..c9c13b296436c 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -12,6 +12,9 @@ class IssuePolicy < IssuablePolicy
     @user && IssueCollection.new([@subject]).visible_to(@user).any?
   end
 
+  desc "User can read contacts belonging to the issue group"
+  condition(:can_read_crm_contacts, scope: :subject) { @user.can?(:read_crm_contact, @subject.project.group) }
+
   desc "Issue is confidential"
   condition(:confidential, scope: :subject) { @subject.confidential? }
 
@@ -77,6 +80,10 @@ class IssuePolicy < IssuablePolicy
   rule { ~persisted & can?(:create_issue) }.policy do
     enable :set_confidentiality
   end
+
+  rule { can?(:set_issue_metadata) & can_read_crm_contacts }.policy do
+    enable :set_issue_crm_contacts
+  end
 end
 
 IssuePolicy.prepend_mod_with('IssuePolicy')
diff --git a/app/services/issues/set_crm_contacts_service.rb b/app/services/issues/set_crm_contacts_service.rb
new file mode 100644
index 0000000000000..13fe30b5ac870
--- /dev/null
+++ b/app/services/issues/set_crm_contacts_service.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module Issues
+  class SetCrmContactsService < ::BaseProjectService
+    attr_accessor :issue, :errors
+
+    MAX_ADDITIONAL_CONTACTS = 6
+
+    def execute(issue)
+      @issue = issue
+      @errors = []
+
+      return error_no_permissions unless allowed?
+      return error_invalid_params unless valid_params?
+
+      determine_changes if params[:crm_contact_ids]
+
+      return error_too_many if too_many?
+
+      add_contacts if params[:add_crm_contact_ids]
+      remove_contacts if params[:remove_crm_contact_ids]
+
+      if issue.valid?
+        ServiceResponse.success(payload: issue)
+      else
+        # The default error isn't very helpful: "Issue customer relations contacts is invalid"
+        issue.errors.delete(:issue_customer_relations_contacts)
+        issue.errors.add(:issue_customer_relations_contacts, errors.to_sentence)
+        ServiceResponse.error(payload: issue, message: issue.errors.full_messages)
+      end
+    end
+
+    private
+
+    def determine_changes
+      existing_contact_ids = issue.issue_customer_relations_contacts.map(&:contact_id)
+      params[:add_crm_contact_ids] = params[:crm_contact_ids] - existing_contact_ids
+      params[:remove_crm_contact_ids] = existing_contact_ids - params[:crm_contact_ids]
+    end
+
+    def add_contacts
+      params[:add_crm_contact_ids].uniq.each do |contact_id|
+        issue_contact = issue.issue_customer_relations_contacts.create(contact_id: contact_id)
+
+        unless issue_contact.persisted?
+          # The validation ensures that the id exists and the user has permission
+          errors << "#{contact_id}: The resource that you are attempting to access does not exist or you don't have permission to perform this action"
+        end
+      end
+    end
+
+    def remove_contacts
+      issue.issue_customer_relations_contacts
+        .where(contact_id: params[:remove_crm_contact_ids]) # rubocop: disable CodeReuse/ActiveRecord
+        .delete_all
+    end
+
+    def allowed?
+      current_user&.can?(:set_issue_crm_contacts, issue)
+    end
+
+    def valid_params?
+      set_present? ^ add_or_remove_present?
+    end
+
+    def set_present?
+      params[:crm_contact_ids].present?
+    end
+
+    def add_or_remove_present?
+      params[:add_crm_contact_ids].present? || params[:remove_crm_contact_ids].present?
+    end
+
+    def too_many?
+      params[:add_crm_contact_ids] && params[:add_crm_contact_ids].length > MAX_ADDITIONAL_CONTACTS
+    end
+
+    def error_no_permissions
+      ServiceResponse.error(message: ['You have insufficient permissions to set customer relations contacts for this issue'])
+    end
+
+    def error_invalid_params
+      ServiceResponse.error(message: ['You cannot combine crm_contact_ids with add_crm_contact_ids or remove_crm_contact_ids'])
+    end
+
+    def error_too_many
+      ServiceResponse.error(payload: issue, message: ["You can only add up to #{MAX_ADDITIONAL_CONTACTS} contacts at one time"])
+    end
+  end
+end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 01f1a98cd2af1..c47b1151f6a16 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -2820,6 +2820,28 @@ Input type: `IssueSetConfidentialInput`
 | <a id="mutationissuesetconfidentialerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
 | <a id="mutationissuesetconfidentialissue"></a>`issue` | [`Issue`](#issue) | Issue after mutation. |
 
+### `Mutation.issueSetCrmContacts`
+
+Input type: `IssueSetCrmContactsInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationissuesetcrmcontactsclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationissuesetcrmcontactscrmcontactids"></a>`crmContactIds` | [`[CustomerRelationsContactID!]!`](#customerrelationscontactid) | Customer relations contact IDs to set. Replaces existing contacts by default. |
+| <a id="mutationissuesetcrmcontactsiid"></a>`iid` | [`String!`](#string) | IID of the issue to mutate. |
+| <a id="mutationissuesetcrmcontactsoperationmode"></a>`operationMode` | [`MutationOperationMode`](#mutationoperationmode) | Changes the operation mode. Defaults to REPLACE. |
+| <a id="mutationissuesetcrmcontactsprojectpath"></a>`projectPath` | [`ID!`](#id) | Project the issue to mutate is in. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationissuesetcrmcontactsclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationissuesetcrmcontactserrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| <a id="mutationissuesetcrmcontactsissue"></a>`issue` | [`Issue`](#issue) | Issue after mutation. |
+
 ### `Mutation.issueSetDueDate`
 
 Input type: `IssueSetDueDateInput`
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ca8befc9ba475..c3f5beeaa5d17 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -34136,7 +34136,7 @@ msgstr ""
 msgid "The connection will time out after %{timeout}. For repositories that take longer, use a clone/push combination."
 msgstr ""
 
-msgid "The contact does not belong to the same group as the issue."
+msgid "The contact does not belong to the same group as the issue"
 msgstr ""
 
 msgid "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository."
diff --git a/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb
new file mode 100644
index 0000000000000..3da702c55d7bb
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/issues/set_crm_contacts_spec.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Setting issues crm contacts' do
+  include GraphqlHelpers
+
+  let_it_be(:user) { create(:user) }
+  let_it_be(:group) { create(:group) }
+  let_it_be(:project) { create(:project, group: group) }
+  let_it_be(:contacts) { create_list(:contact, 4, group: group) }
+
+  let(:issue) { create(:issue, project: project) }
+  let(:operation_mode) { Types::MutationOperationModeEnum.default_mode }
+  let(:crm_contact_ids) { [global_id_of(contacts[1]), global_id_of(contacts[2])] }
+  let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" }
+
+  let(:mutation) do
+    variables = {
+      project_path: issue.project.full_path,
+      iid: issue.iid.to_s,
+      operation_mode: operation_mode,
+      crm_contact_ids: crm_contact_ids
+    }
+
+    graphql_mutation(:issue_set_crm_contacts, variables,
+                     <<-QL.strip_heredoc
+                       clientMutationId
+                       errors
+                       issue {
+                         customerRelationsContacts {
+                           nodes {
+                             id
+                           }
+                         }
+                       }
+                     QL
+    )
+  end
+
+  def mutation_response
+    graphql_mutation_response(:issue_set_crm_contacts)
+  end
+
+  before do
+    create(:issue_customer_relations_contact, issue: issue, contact: contacts[0])
+    create(:issue_customer_relations_contact, issue: issue, contact: contacts[1])
+  end
+
+  context 'when the user has no permission' do
+    it 'returns expected error' do
+      error = Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR
+      post_graphql_mutation(mutation, current_user: user)
+
+      expect(graphql_errors).to include(a_hash_including('message' => error))
+    end
+  end
+
+  context 'when the user has permission' do
+    before do
+      group.add_reporter(user)
+    end
+
+    context 'when the feature is disabled' do
+      before do
+        stub_feature_flags(customer_relations: false)
+      end
+
+      it 'raises expected error' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(graphql_errors).to include(a_hash_including('message' => 'Feature disabled'))
+      end
+    end
+
+    context 'replace' do
+      it 'updates the issue with correct contacts' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id))
+          .to match_array([global_id_of(contacts[1]), global_id_of(contacts[2])])
+      end
+    end
+
+    context 'append' do
+      let(:crm_contact_ids) { [global_id_of(contacts[3])] }
+      let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] }
+
+      it 'updates the issue with correct contacts' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id))
+          .to match_array([global_id_of(contacts[0]), global_id_of(contacts[1]), global_id_of(contacts[3])])
+      end
+    end
+
+    context 'remove' do
+      let(:crm_contact_ids) { [global_id_of(contacts[0])] }
+      let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] }
+
+      it 'updates the issue with correct contacts' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(graphql_data_at(:issue_set_crm_contacts, :issue, :customer_relations_contacts, :nodes, :id))
+          .to match_array([global_id_of(contacts[1])])
+      end
+    end
+
+    context 'when the contact does not exist' do
+      let(:crm_contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] }
+
+      it 'returns expected error' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(graphql_data_at(:issue_set_crm_contacts, :errors))
+          .to match_array(["Issue customer relations contacts #{non_existing_record_id}: #{does_not_exist_or_no_permission}"])
+      end
+    end
+
+    context 'when the contact belongs to a different group' do
+      let(:group2) { create(:group) }
+      let(:contact) { create(:contact, group: group2) }
+      let(:crm_contact_ids) { [global_id_of(contact)] }
+
+      before do
+        group2.add_reporter(user)
+      end
+
+      it 'returns expected error' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(graphql_data_at(:issue_set_crm_contacts, :errors))
+        .to match_array(["Issue customer relations contacts #{contact.id}: #{does_not_exist_or_no_permission}"])
+      end
+    end
+
+    context 'when attempting to add more than 6' do
+      let(:operation_mode) { Types::MutationOperationModeEnum.enum[:append] }
+      let(:gid) { global_id_of(contacts[0]) }
+      let(:crm_contact_ids) { [gid, gid, gid, gid, gid, gid, gid] }
+
+      it 'returns expected error' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(graphql_data_at(:issue_set_crm_contacts, :errors))
+          .to match_array(["You can only add up to 6 contacts at one time"])
+      end
+    end
+
+    context 'when trying to remove non-existent contact' do
+      let(:operation_mode) { Types::MutationOperationModeEnum.enum[:remove] }
+      let(:crm_contact_ids) { ["gid://gitlab/CustomerRelations::Contact/#{non_existing_record_id}"] }
+
+      it 'raises expected error' do
+        post_graphql_mutation(mutation, current_user: user)
+
+        expect(graphql_data_at(:issue_set_crm_contacts, :errors)).to be_empty
+      end
+    end
+  end
+end
diff --git a/spec/services/issues/set_crm_contacts_service_spec.rb b/spec/services/issues/set_crm_contacts_service_spec.rb
new file mode 100644
index 0000000000000..65b22fe3b3577
--- /dev/null
+++ b/spec/services/issues/set_crm_contacts_service_spec.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Issues::SetCrmContactsService do
+  let_it_be(:user) { create(:user) }
+  let_it_be(:group) { create(:group) }
+  let_it_be(:project) { create(:project, group: group) }
+  let_it_be(:contacts) { create_list(:contact, 4, group: group) }
+
+  let(:issue) { create(:issue, project: project) }
+  let(:does_not_exist_or_no_permission) { "The resource that you are attempting to access does not exist or you don't have permission to perform this action" }
+
+  before do
+    create(:issue_customer_relations_contact, issue: issue, contact: contacts[0])
+    create(:issue_customer_relations_contact, issue: issue, contact: contacts[1])
+  end
+
+  subject(:set_crm_contacts) do
+    described_class.new(project: project, current_user: user, params: params).execute(issue)
+  end
+
+  describe '#execute' do
+    context 'when the user has no permission' do
+      let(:params) { { crm_contact_ids: [contacts[1].id, contacts[2].id] } }
+
+      it 'returns expected error response' do
+        response = set_crm_contacts
+
+        expect(response).to be_error
+        expect(response.message).to match_array(['You have insufficient permissions to set customer relations contacts for this issue'])
+      end
+    end
+
+    context 'when user has permission' do
+      before do
+        group.add_reporter(user)
+      end
+
+      context 'when the contact does not exist' do
+        let(:params) { { crm_contact_ids: [non_existing_record_id] } }
+
+        it 'returns expected error response' do
+          response = set_crm_contacts
+
+          expect(response).to be_error
+          expect(response.message).to match_array(["Issue customer relations contacts #{non_existing_record_id}: #{does_not_exist_or_no_permission}"])
+        end
+      end
+
+      context 'when the contact belongs to a different group' do
+        let(:group2) { create(:group) }
+        let(:contact) { create(:contact, group: group2) }
+        let(:params) { { crm_contact_ids: [contact.id] } }
+
+        before do
+          group2.add_reporter(user)
+        end
+
+        it 'returns expected error response' do
+          response = set_crm_contacts
+
+          expect(response).to be_error
+          expect(response.message).to match_array(["Issue customer relations contacts #{contact.id}: #{does_not_exist_or_no_permission}"])
+        end
+      end
+
+      context 'replace' do
+        let(:params) { { crm_contact_ids: [contacts[1].id, contacts[2].id] } }
+
+        it 'updates the issue with correct contacts' do
+          response = set_crm_contacts
+
+          expect(response).to be_success
+          expect(issue.customer_relations_contacts).to match_array([contacts[1], contacts[2]])
+        end
+      end
+
+      context 'add' do
+        let(:params) { { add_crm_contact_ids: [contacts[3].id] } }
+
+        it 'updates the issue with correct contacts' do
+          response = set_crm_contacts
+
+          expect(response).to be_success
+          expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[1], contacts[3]])
+        end
+      end
+
+      context 'remove' do
+        let(:params) { { remove_crm_contact_ids: [contacts[0].id] } }
+
+        it 'updates the issue with correct contacts' do
+          response = set_crm_contacts
+
+          expect(response).to be_success
+          expect(issue.customer_relations_contacts).to match_array([contacts[1]])
+        end
+      end
+
+      context 'when attempting to add more than 6' do
+        let(:id) { contacts[0].id }
+        let(:params) { { add_crm_contact_ids: [id, id, id, id, id, id, id] } }
+
+        it 'returns expected error message' do
+          response = set_crm_contacts
+
+          expect(response).to be_error
+          expect(response.message).to match_array(['You can only add up to 6 contacts at one time'])
+        end
+      end
+
+      context 'when trying to remove non-existent contact' do
+        let(:params) { { remove_crm_contact_ids: [non_existing_record_id] } }
+
+        it 'returns expected error message' do
+          response = set_crm_contacts
+
+          expect(response).to be_success
+          expect(response.message).to be_nil
+        end
+      end
+
+      context 'when combining params' do
+        let(:error_invalid_params) { 'You cannot combine crm_contact_ids with add_crm_contact_ids or remove_crm_contact_ids' }
+
+        context 'add and remove' do
+          let(:params) { { remove_crm_contact_ids: [contacts[1].id], add_crm_contact_ids: [contacts[3].id] } }
+
+          it 'updates the issue with correct contacts' do
+            response = set_crm_contacts
+
+            expect(response).to be_success
+            expect(issue.customer_relations_contacts).to match_array([contacts[0], contacts[3]])
+          end
+        end
+
+        context 'replace and remove' do
+          let(:params) { { crm_contact_ids: [contacts[3].id], remove_crm_contact_ids: [contacts[0].id] } }
+
+          it 'returns expected error response' do
+            response = set_crm_contacts
+
+            expect(response).to be_error
+            expect(response.message).to match_array([error_invalid_params])
+          end
+        end
+
+        context 'replace and add' do
+          let(:params) { { crm_contact_ids: [contacts[3].id], add_crm_contact_ids: [contacts[1].id] } }
+
+          it 'returns expected error response' do
+            response = set_crm_contacts
+
+            expect(response).to be_error
+            expect(response.message).to match_array([error_invalid_params])
+          end
+        end
+      end
+    end
+  end
+end
-- 
GitLab