Syncing data from Dynamics CRM directly to xDB

If you’ve ever worked with the Dynamics CRM Campaign Integration module from Sitecore, you know that this will sync up Contacts in CRM with the Membership database in Sitecore.  If you have users authenticating in Sitecore with credentials from CRM, this is still the preferred form of integration.  The problem, however, is that Users in Sitecore’s membership database are not directly tied to Contacts in xDB.  Unless you create this connection yourself, of course.  So, if you’re not having users authenticate with credentials from CRM, this creates a bit of an unnecessary disconnect.  Because of this, I decided to throw together a POC where I sync CRM contacts directly to xDB.  Since CRM tends to hold richer data about any given customer, account or contact, we could pull in this information and tailor their experience based on that info.

Recap

In a previous post, I described the basics of what I was trying to accomplish – create an abstract framework around which data from Contacts in CRM could be sync’d with their contact record in xDB.  But now that the POC is done, here’s more detail about the framework.

moar-code

The Framework

The source code for this POC framework can be found here.  

There are five projects involved in this solution.

  • CitizenSc.CrmConnector.Model – contains an interface model of an external CRM contact (could be Dynamics, could be Salesforce, could be a custom CRM, etc…)
  • CitizenSc.CrmConnector.Model.DynamicsCrm – contains an implementation of the interfaces included in CrmConnector.Model
  • CitizenSc.CrmConnector.Service – contains the interface definition for ICrmConnector
  • CitizenSc.CrmConnector.Service.DynamicsCrm – this is the implementation of ICrmConnector, but also includes the early binding proxy classes created with CrmSvcUtil.
  • CitizenSc.CrmConnector.Web – contains the config file with the patch configuration for the pipeline processor and the pipeline processor itself

While I won’t spell out, in detail, the entire model (go check out the source code), here’s the definition for ICrmContact and ICrmConnector.  In it, I’m defining the functions that must be provided by a future implementation.  While this is certainly not an exhaustive list of properties and methods I’d create and use in a real scenario, it was more than enough to satisfy the POC and the customer demo.

public interface ICrmContact
{
   IPersonalInformation PersonalInformation { get; set; }
   ICollection<IEmailAddress> EmailAddresses { get; set; }
   ICollection<IPhoneNumber> PhoneNumbers { get; set; }
   ICollection<IAddress> Addresses { get; set; }
}

public interface ICrmConnector
{
   bool DoesEntityExistInCrm(string uniqueIdentifier);
   ICrmContact GetInformationFromCrm(string uniqueIdentifier);
   void SyncCrmToXdb(ICrmContact crmContact);
}

I will use this interface later on in my pipeline processor that’ll I’ll patch in to the SessionEnd pipeline.

The Implementation

While this implementation is targeted at Dynamics CRM, it would be just as easy to create an implementation for <insert your CRM here>.

I’ll start by abstracting a few dependencies to make testing easier…

public interface ILogger
{
   void Info(string message, object owner);
   void Warning(string message, object owner);
   void Error(string message, Exception ex, object owner);
}
public class SitecoreLogger : ILogger
{
   public void Info(string message, object owner)
   {
      Sitecore.Diagnostics.Log.Info(message, owner);
   }

   public void Warning(string message, object owner)
   {
      Sitecore.Diagnostics.Log.Warn(message, owner);
   }

   public void Error(string message, Exception ex, object owner)
   {
      Sitecore.Diagnostics.Log.Error(message, ex, owner);
   }
}
public interface IAnalytics
{
   ITracker Tracker { get; }
   Sitecore.Analytics.Tracking.Contact GetCurrentContact();
}
public class SitecoreAnalytics : IAnalytics
{
   public ITracker Tracker { get { return Sitecore.Analytics.Tracker.Current; } }

   public Sitecore.Analytics.Tracking.Contact GetCurrentContact()
   {
      Sitecore.Analytics.Tracking.Contact contact = null;

      if (Tracker != null && Tracker.IsActive)
      {
         contact = Tracker.Contact;
      }

      return contact;
   }
}
public interface ICrmServiceWrapper
{
   Contact FindContact(Expression<Func<Contact, bool>> predicate);
   void UpdateCrm(Contact contact);
}
public class DynamicsCrmServiceWrapper : ICrmServiceWrapper
{
   private ILogger _logger;

   public DynamicsCrmServiceWrapper() : this(new SitecoreLogger())
   {
   }

   public DynamicsCrmServiceWrapper(ILogger logger)
   {
      _logger = logger;
   }

   public Contact FindContact(Expression<Func<Contact, bool>> predicate)
   {
      Contact returnValue = null;

      GetConnection(context =>
      {
         returnValue = context.ContactSet.First(predicate);
      });

      return returnValue;
   }

   public void UpdateCrm(Contact contact)
   {
      GetConnection(context =>
      {
         context.UpdateObject(contact);
         context.SaveChanges();
      });
   }

   private void GetConnection(Action<MyDynamicsServiceContext> action)
   {
      _logger.Info("Connecting to CRM...", this);
      var connection = new CrmConnection("CRMServiceConnection");
      using (var org = new OrganizationService(connection))
      {
         try
         {
            var context = new MyDynamicsServiceContext(org);
            action(context);
         }
         catch (Exception ex)
         {
            //Log the exception, then let the business layer handle the error scenario
            _logger.Error("There was a problem connecting to CRM", ex, this);
            throw ex;
         }
      }
   }
}

Now for the ICrmConnector implementation:

public class DynamicsCrmConnector : ICrmConnector
{
   private ICrmServiceWrapper _crmService;
   private ILogger _logger;
   private IAnalytics _analytics;

   public DynamicsCrmConnector() : this(new DynamicsCrmServiceWrapper(), new SitecoreLogger(), new SitecoreAnalytics())
   {
         
   }

   public DynamicsCrmConnector(ICrmServiceWrapper crmService, ILogger logger, IAnalytics analytics)
   {
      _crmService = crmService;
      _logger = logger;
      _analytics = analytics;

      DynamicsMapper.Configure();
   }

   public bool DoesEntityExistInCrm(string uniqueIdentifier)
   {
      var returnValue = false;

      try
      {
         returnValue = _crmService.FindContact(c => c.EMailAddress1 == uniqueIdentifier) != null;
      }
      catch (Exception ex)
      {
         _logger.Error("There was an error attempting to find an entity in CRM for " + uniqueIdentifier, ex, this);
      }

      return returnValue;
   }

   public ICrmContact GetInformationFromCrm(string uniqueIdentifier)
   {
      CrmContact crmContact = null;

      try
      {
         _logger.Info("Attempting to find a contact where EMailAddress1 = " + uniqueIdentifier, this);
         var result = _crmService.FindContact(c => c.EMailAddress1 == uniqueIdentifier);
         if (result != null)
         {
            _logger.Info("Found a contact in CRM.", this);
            crmContact = Mapper.Map<Contact, CrmContact>(result);
         }
         else
         {
            _logger.Warning("A contact could not be found for " + uniqueIdentifier, this);
         }
      }
      catch (Exception ex)
      {
         _logger.Error("There was an error getting contact information from CRM", ex, this);
      }

      return crmContact;
   }

   public void SyncCrmToXdb(ICrmContact crmContact)
   {
      try
      {
         _logger.Info("Syncing CRM Contact to Tracker.Current.Contact", this);
            
         var xdbContact = _analytics.GetCurrentContact();

         if (xdbContact != null)
         {
            var emailFacet = xdbContact.GetFacet<IContactEmailAddresses>("Emails");
            var addressFacet = xdbContact.GetFacet<IContactAddresses>("Addresses");
            var personalFacet = xdbContact.GetFacet<IContactPersonalInfo>("Personal");
            var phoneFacet = xdbContact.GetFacet<IContactPhoneNumbers>("Phone Numbers");
            var email = emailFacet.Entries.Contains("Work Email") ? emailFacet.Entries["Work Email"] : emailFacet.Entries.Create("Work Email");
            var address = addressFacet.Entries.Contains("Work Address") ? addressFacet.Entries["Work Address"] : addressFacet.Entries.Create("Work Address");
            var workPhone = phoneFacet.Entries.Contains("Work Phone") ? phoneFacet.Entries["Work Phone"] : phoneFacet.Entries.Create("Work Phone");

            if (crmContact.EmailAddresses.Any())
            {
               email.SmtpAddress = crmContact.EmailAddresses.First().Value;
               emailFacet.Preferred = "Work Email";
            }
            if (crmContact.Addresses.Any())
            {
               address.StreetLine1 = crmContact.Addresses.First().StreetLine1;
               address.StreetLine2 = crmContact.Addresses.First().StreetLine2;
               address.StreetLine3 = crmContact.Addresses.First().StreetLine3;
               address.City = crmContact.Addresses.First().City;
               address.StateProvince = crmContact.Addresses.First().StateProvince;
               address.PostalCode = crmContact.Addresses.First().PostalCode;
               address.Country = crmContact.Addresses.First().Country;
            }
            if (crmContact.PhoneNumbers.Any())
            {
               phoneFacet.Preferred = "Work Phone";
               workPhone.Number = crmContact.PhoneNumbers.First().Value;
            }

            personalFacet.Title = crmContact.PersonalInformation.Title;
            personalFacet.JobTitle = crmContact.PersonalInformation.JobTitle;
            personalFacet.FirstName = crmContact.PersonalInformation.FirstName;
            personalFacet.MiddleName = crmContact.PersonalInformation.MiddleName;
            personalFacet.Surname = crmContact.PersonalInformation.LastName;
            personalFacet.Gender = crmContact.PersonalInformation.Gender;
            personalFacet.BirthDate = crmContact.PersonalInformation.BirthDate;
            _logger.Info("Finished syncing CRM Contact", this); 
         }
         else
         {
            _logger.Warning("The current Tracker.Contact was null", this);
         }
      }
      catch (Exception ex)
      {
         _logger.Error("There was a problem syncing the CRM Contact", ex, this);
      }
   }
}

I am using AutoMapper to map between the Contact object and my CrmContact object.  Less because of the need to reuse this mapping, but more because I like AutoMapper.  🙂

Here are the custom ValueResolvers I created for the AutoMapper config:

public class GenderResolver : ValueResolver<Contact, String>
{
   protected override string ResolveCore(Contact source)
   {
      var genderString = String.Empty;

      if (source.GenderCode != null)
      {
         genderString = source.GenderCode.Value == 1 ? "Male" : "Female";
      }

      return genderString;
   }
}

public class EmailCollectionResolver : ValueResolver<Contact, ICollection<Model.IEmailAddress>>
{
   protected override ICollection<Model.IEmailAddress> ResolveCore(Contact source)
   {
      var collection = new List<Model.IEmailAddress>();

      if (!String.IsNullOrEmpty(source.EMailAddress1))
         collection.Add(new EmailAddress() { Value = source.EMailAddress1, Preferred = true });

      if (!String.IsNullOrEmpty(source.EMailAddress2))
         collection.Add(new EmailAddress() { Value = source.EMailAddress2, Preferred = false });

      if (!String.IsNullOrEmpty(source.EMailAddress3))
         collection.Add(new EmailAddress() { Value = source.EMailAddress3, Preferred = false });

      return collection;
   }
}

public class AddressCollectionResolver : ValueResolver<Contact, ICollection<Model.IAddress>>
{
   protected override ICollection<Model.IAddress> ResolveCore(Contact source)
   {
      var collection = new List<Model.IAddress>();

      if (!String.IsNullOrEmpty(source.Address1_Line1))
      {
         var address = new Address();

         address.StreetLine1 = source.Address1_Line1;
         address.StreetLine2 = source.Address1_Line2 ?? String.Empty;
         address.StreetLine3 = source.Address1_Line3 ?? String.Empty;
         address.City = source.Address1_City ?? String.Empty;
         address.StateProvince = source.Address1_StateOrProvince ?? String.Empty;
         address.PostalCode = source.Address1_PostalCode ?? String.Empty;
         address.Country = source.Address1_Country ?? String.Empty;
         address.Preferred = true;

         collection.Add(address);
      }

      return collection;
   }
}

public class PhoneNumberCollectionResolver : ValueResolver<Contact, ICollection<Model.IPhoneNumber>>
{
   protected override ICollection<Model.IPhoneNumber> ResolveCore(Contact source)
   {
      var collection = new List<Model.IPhoneNumber>();

      if (!String.IsNullOrEmpty(source.MobilePhone))
         collection.Add(new PhoneNumber() { Value = source.MobilePhone, Preferred = true, Type = "Mobile" });

      if (!String.IsNullOrEmpty(source.Fax))
         collection.Add(new PhoneNumber() { Value = source.Fax, Preferred = false, Type = "Fax" });

      return collection;
   }
}

public class DynamicsMapper
{
   public static void Configure()
   {
      Mapper.CreateMap<Contact, PersonalInformation>()
         .ForMember(d => d.FirstName, o => o.MapFrom(s => s.FirstName))
         .ForMember(d => d.MiddleName, o => o.MapFrom(s => s.MiddleName))
         .ForMember(d => d.LastName, o => o.MapFrom(s => s.LastName))
         .ForMember(d => d.Title, o => o.MapFrom(s => s.JobTitle))
         .ForMember(d => d.Nickname, o => o.MapFrom(s => s.NickName))
         .ForMember(d => d.Gender, o => o.ResolveUsing<GenderResolver>())
         .ForMember(d => d.BirthDate, o => o.MapFrom(s => s.BirthDate))
         ;

      Mapper.CreateMap<Contact, CrmContact>()
         .ForMember(d => d.PersonalInformation, o => o.MapFrom(s => Mapper.Map<PersonalInformation>(s)))
         .ForMember(d => d.EmailAddresses, o => o.ResolveUsing<EmailCollectionResolver>())
         .ForMember(d => d.Addresses, o => o.ResolveUsing<AddressCollectionResolver>())
         .ForMember(d => d.PhoneNumbers, o => o.ResolveUsing<PhoneNumberCollectionResolver>())
         ;
   }
}

Hooking it into the Pipeline

On SessionEnd, if we’re dealing with a known, identified contact in Sitecore, we’ll call CRM to get updated data and sync it into xDB.

public class SyncCrmToXdb
{
   public ICrmConnector CrmConnector { get; set; }

   public void Process(SessionEndArgs args)
   {
      Sitecore.Diagnostics.Log.Info("Calling SyncCrmToXdb.Process", this);
      if (Tracker.Current != null)
      {
         var xdbContact = Tracker.Current.Contact;
         if (Tracker.Current.Contact.Identifiers.IdentificationLevel == ContactIdentificationLevel.Known)
         {
            Sitecore.Diagnostics.Log.Info("The contact is identified - calling CRM to get contact info...", this);
            var crmContact = CrmConnector.GetInformationFromCrm(xdbContact.Identifiers.Identifier);
            if (crmContact != null)
               CrmConnector.SyncCrmToXdb(crmContact);
         }
      }
   }
}
<sitecore>
   <pipelines>
      <sessionEnd>
         <processor type="CitizenSc.CrmConnector.Web.Processors.SessionEnd.SyncCrmToXdb, CitizenSc.CrmConnector.Web" patch:before="processor[@type='Sitecore.Analytics.Pipelines.SessionEnd.RaiseVisitEnd,Sitecore.Analytics']">
            <CrmConnector type="CitizenSc.CrmConnector.Service.DynamicsCrm.DynamicsCrmContext, CitizenSc.CrmConnector.Service.DynamicsCrm" />
         </processor>
      </sessionEnd>
   </pipelines>
</sitecore>

On SessionEnd, I’m only getting data from CRM.  I’m not pushing any data.  Since I want CRM to continue to be the system of record, I want to be much more deliberate on pushing any data to CRM.  I’m currently doing this through WFFM save actions.

Bthis was a POC, I didn’t create a method for custom property mapping between CRM to xDB in a config file like the CRM Security Provider does.  But in a real world scenario, this would be a given.  I’ll build that out later.

Happy Sitecore trails, my friends!

  One thought on “Syncing data from Dynamics CRM directly to xDB

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: