Friday, June 15, 2018

FIFA World Cup part 3 of 4 - Marketing Automation

Part 3 in a series of 4
  1. Introduction
  2. Sitecore Forms
  3. Marketing Automation
  4. XConnect
Having my contestants signed up, I'm ready to watch some football (yes, football!). As results starts rolling in, I need to evaluate the bets and award points.
I have chosen to use the Marketing Automation module for this. Meaning that I need to create a plan for each match.

Now, I could have gone the easy way and created all the plans before opening the signup, and just let the start action enroll contacts once they were awarded the goal in the form. However that would require that I had actually gotten that far before I opened the forms. I figured out another way, I'll get back to that later.


Creating an action

For each contact I need to be able to evaluate the bet. The easiest way to do this would be to insert a Custom Listener, and create a custom condition to evaluate the facet value against the result.
This was my first solution, and it worked just fine - but since I'm doing this to learn, I wanted to create a custom action instead.

My first instinct was to link my MatchResult action to each Sitecore match item created in part 1 of the series. However as these actions run on the XConnect application, they don't have the same direct access to Sitecore items. I'm sure this could have been made, but as all I need is the match name and the result, I just made fields for this, and found out another way to spare me the double work, more on that later.

Again, Sitecore did a good job documenting this work, and I won't bore you with the details. The code I implemented for the action is simple:

using Microsoft.Extensions.Logging;
using Sitecore.Marketing.Automation.Activity;
using Sitecore.Xdb.MarketingAutomation.Core.Activity;
using Sitecore.Xdb.MarketingAutomation.Core.Processing.Plan;
using WorldCup.XConnect.Facets;

namespace WorldCup.XConnect.Activities
{
    public class MatchResult : BaseListener
    {
        public MatchResult(ILogger<IActivity> logger) : base(logger)
        {
        }

        public string Match { get; set; }

        public string Result { get; set; }
        
        public override ActivityResult Invoke(IContactProcessingContext context)
        {
            //Wait until match is played
            if (string.IsNullOrWhiteSpace(Result))
            {
                return new SuccessStay();
            }

            var bets = GetContactFacet<WorldCupBets>(context.Contact);
            if (bets?.Matches != null && bets.Matches.ContainsKey(Match) && bets.Matches[Match] == Result)
            {
                return new SuccessMove(TruePathKey);
            }
            return new SuccessMove(FalsePathKey);
        }
    }
}

This relies on my custom WorldCupBets contact facet (described in part 2). So I needed to configure the marketing automation engine to load this facet, as described by Sitecore.


Making the UI

Oh joy! Now we have to make the user interface - This means I get my first dive into the new Angular/TypeScript frontend implementation as well. Again, documented by Sitecore but for beginners like me, there was a few steps missing. Google helped me out with a more detailed guide.

After a few trials and errors, I got it all compiling, but no button appeared. I went over both guides again, but I couldn't figure out what was missing. I had no errors in logs or the browsers developer window. The few scraps I had left of joy for TypeScript and Angular quickly fizzled away.
After buckling down and stepping through the unreadable (to me) compiled javascript. I finally found the issue. In the plugin definition typescript file, we need to add the Sitecore ID of the editor item. Both guides just say copy/paste the ID, however it's necessary to lower-case all the letters in the guid as well.

My plugin definition - note that the highlighted id must be lowercase

Creating the plans

Now that I got my custom action, I'm ready to create a plan template that I want all my plans to follow. Basically I just want the plan to evaluate once the match is decided, and then score the contacts and move them to winners and loosers brackets (just to see that it all went as expected. It looks like this:

The automation plan template, hovering the MatchResult action

Since I already realized I was too late on this part, as the forms was already starting to roll in, I am in no hurry to create these forms ahead of time. I can create as they're needed.

I decided on making an item:saved handler to check if the match was scored (and for safety, that the match had actually begun). And then let that handler do my dirty work:

  1. Check that the match is ready for scoring
  2. Duplicate the template plan
    Unfortunately duplicate didn't exist as part of Sitecore's backend API, so I made it.
  3. Set the plan name and match values during duplication
  4. Enroll a contact list to the plan
    This didn't exist either, so I had to make it as well.
    The contact list is easily creating a segmented list in List Manager, and segmenting on all contacts that triggered the goal from the signup form.

The implementation of the item:saved handler was done this way:

using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Events;
using Sitecore.Marketing.Automation.Model;
using System;
using WorldCup.Services;

namespace WorldCup.EventHandlers
{
    public class WorldCupHandler
    {
        private TemplateID _matchTemplate = new TemplateID(new ID("{C278F392-C1C9-4EAF-B51F-3A365C044906}"));
        private Guid _automationPlanTemplate = new Guid("f4a69cc0-ef91-4c0c-8643-09e9f67acf6c");
        private Guid _matchResultActivityId = new Guid("2A001EDE-994D-43F0-BBEF-944554A71261");
        private Guid _participantsContactList = new Guid("6eeb2f24-fae3-440d-d242-3f4b4eb47926");

        protected void OnItemSaved(object sender, EventArgs args)
        {
            if (args == null)
            {
                return;
            }
            
            Item item = Event.ExtractParameter(args, 0) as Item;
            if (item == null || item.Database.Name != "master" || item.TemplateID != _matchTemplate)
            {
                return;
            }
            
            if (!string.IsNullOrWhiteSpace(item["Result"]) && DateTime.Now.ToUniversalTime() > new DateField(item.Fields["Start Date"]).DateTime)
            {
                TriggerMatchScoring(item.Name, item["Result"]);
            }
        }

        private void TriggerMatchScoring(string match, string result)
        {
            var culture = Sitecore.Context.Culture;
            var automationPlanService = new AutomationPlanService();
            
            //duplicate plan
            var planId = automationPlanService.DuplicateAutomationPlan(_automationPlanTemplate, culture.TwoLetterISOLanguageName, 
                                                                        plan => plan.Name = $"WorldCup {match}", 
                                                                        action => Set(action, match, result)).Result;
            if (planId == Guid.Empty)
            {
                return;
            }

            //activate the plan
            automationPlanService.ActivateAutomationPlan(planId, culture.TwoLetterISOLanguageName);

            //enroll the contacts
            var xConnectService = new XConnectService();
            xConnectService.EnrollListToMarketingAutomation(_participantsContactList, culture, planId);
        }

        private void Set(AutomationActivityDefinitionViewModel activity, string match, string result)
        {
            if (activity.ActivityTypeId == _matchResultActivityId)
            {
                AutomationPlanService.SetActivityParameter(activity, "Match", match);
                AutomationPlanService.SetActivityParameter(activity, "Result", result);
            }
        }
    }
}

And that's it. Whenever I save the result to the match items in Sitecore, all contestants are automatically scored through a new automation plan. Now for anyone that would use this on a "production" site with lots of stuff happening, you would of course have to implement extra checks to make sure double scoring didn't happen if the item was saved again etc. However for brevity I kept it simple.